---
title: "Java SDK for Spring Boot and servlet apps · camada"
description: "Install camada in Spring Boot or any servlet container with one jakarta.servlet.Filter: the options it takes, what this position sees, how it fails open."
url: https://www.camada.app/docs/java
last_updated: 2026-10-26
---

Docs

# Java: Spring Boot, any servlet container

`dev.camada:camada` enforces your snapshot inside a JVM: your ordered custom rules, then allow, block and challenge, decided in-process, with events shipped off the request path. One artifact with a plain `jakarta.servlet.Filter` that Spring Boot, Tomcat, Jetty and Undertow all take as is.

## Install

Not on Maven Central yet. From your project's folder: clone it next to the project, build it into your local Maven repository, then add the dependency.

shell, then pom.xml

```text
git clone https://github.com/camada-app/camada-java ../camada-java
mvn -q -f ../camada-java install -DskipTests

<!-- then, in pom.xml -->
<dependency>
  <groupId>dev.camada</groupId>
  <artifactId>camada</artifactId>
  <version>0.2.0</version>
</dependency>
```

Java 17 or newer; the only runtime dependency is the servlet API your container already provides.

## Mount it

Spring Boot

```java
// CamadaConfig.java, beside your @SpringBootApplication class
package com.example.demo;   // your application's package

import dev.camada.servlet.CamadaFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;

@Configuration
class CamadaConfig {
  @Bean
  FilterRegistrationBean<CamadaFilter> camadaFilter() {
    var reg = new FilterRegistrationBean<>(new CamadaFilter());
    reg.setOrder(Ordered.HIGHEST_PRECEDENCE);   // first, so camada answers before routing
    return reg;
  }
}
```

any servlet container

```java
// CamadaListener.java: any servlet container (or declare the filter first in web.xml)
import dev.camada.servlet.CamadaFilter;
import jakarta.servlet.ServletContextEvent;
import jakarta.servlet.ServletContextListener;
import jakarta.servlet.annotation.WebListener;

@WebListener
public class CamadaListener implements ServletContextListener {
  @Override
  public void contextInitialized(ServletContextEvent event) {
    event.getServletContext()
        .addFilter("camada", new CamadaFilter())
        .addMappingForUrlPatterns(null, false, "/*");   // false: ahead of the filters web.xml declares
  }
}
```

The filter shares one lazy engine (`Camada.getDefault()`), built from the environment on the first request:

shell

```sh
export CAMADA_KEY=<ingest_token>.<snap_token>
export CAMADA_INGEST_URL=http://localhost:8787   # dev only; defaults to production ingest
```

Set these as environment variables: export them in the shell that starts your app, or add them in your host's settings. camada reads only the environment, and Spring Boot does not pass application.properties or a .env file to it.

That build starts the snapshot poll on a daemon thread and never blocks, so the request that triggered it is answered cold: it passes (fail open), and so does anything that arrives before the first poll lands. To enforce from request 1, warm the engine at startup; `warmUp(ms)` polls until the snapshot is no longer cold or the budget is spent, returns false rather than throwing, and is a no-op on an inert engine:

warm-up

```java
// CamadaConfig.java: two more imports…
import dev.camada.Camada;
import org.springframework.boot.ApplicationRunner;

// …and one more @Bean in the class, run once the app has started
@Bean
ApplicationRunner camadaWarmUp() {
  // builds the engine (its boot poll is already running on its thread), then waits for that poll:
  // bounded, so an unreachable analyst leaves it cold, and the app still fails open
  return args -> Camada.getDefault().warmUp(5000);
}
```

The default is built once, by the first caller, with that caller's options: when the filter was handed `Options`, warm through `camadaFilter.engine()` rather than a bare `Camada.getDefault()`. The static helpers (`Camada.scriptTag`, `track`, `serveChallenge`) never build it: before the filter's first request they are silent no-ops.

An app that reads its own configuration can build the engine itself and hand it in — or hand the filter the options it should build the default engine with:

explicit config

```java
// CamadaConfig.java, for an app that reads its own configuration: these imports too…
import dev.camada.Camada;
import dev.camada.Options;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;

// …and this filter bean in place of the one above
@Bean
FilterRegistrationBean<CamadaFilter> camadaFilter(
    @Value("${camada.key}") String key, @Value("${camada.ingest-url}") String ingest) {
  Camada engine = new Camada(new Options()
      .env(Map.of("CAMADA_KEY", key, "CAMADA_INGEST_URL", ingest))
      .challengePath("/api/__camada/challenge"));
  var reg = new FilterRegistrationBean<>(new CamadaFilter(engine));   // this engine, not the default
  // or new CamadaFilter(new Options().env(...)): the default engine, built with these options on the first request
  reg.setOrder(Ordered.HIGHEST_PRECEDENCE);
  return reg;
}
```

Quickstart: [Spring Boot](https://www.camada.app/docs/quickstart/spring).

## The options you will use

| Option | Default | Meaning |
| --- | --- | --- |
| `env` | `System.getenv()` | Where CAMADA_KEY and friends are read from. Pass a Map to configure in code. |
| `transport` | `java.net.http` | The HTTP seam that reaches the analyst; tests hand in an in-process fake. |
| `refreshS` | `server-steered` | Poll cadence in seconds. Unset, the tenant config's poll_seconds steers it; set, it is pinned. |
| `challenge` | `true` | Serve the proof-of-work page for challenge verdicts (CAMADA_CHALLENGE=0 switches it off too). |
| `challengePath` | `/__camada/challenge` | Where that page posts its solution. |
| `snapshotVersion` | `5` | 4 drops your ordered custom rules; 3 the allow and challenge sections too. |
| `scriptPath` | `/_cam/b.js` | Where the first-party beacon script is served. |
| `fpPath` | `/_cam/fp` | Where that script posts the beacon. Keep it in the same directory as scriptPath: the script derives one from the other. |

Environment: `CAMADA_KEY`, `CAMADA_INGEST_URL`, `CAMADA_SNAPSHOT_URL`, `CAMADA_TRUSTED_PROXY`, `CAMADA_SERVERLESS=1` (a per-request staleness check that hands the refresh to the daemon thread), `CAMADA_CHALLENGE=0`, and the kill switch `CAMADA_DISABLED=1`, checked per request (set at boot, no threads start at all). The [docs index](https://www.camada.app/docs) lists what each one means.

## What it does per request

1. Keeps the snapshot fresh off the request path: a daemon poll thread at the cadence your tenant config sets (at least 5 s; gzip on the wire, 304 when nothing changed), asking for snapshot v5, the container that carries your ordered custom rules. The request path never waits on the network.
2. Resolves the client from the socket peer (`getRemoteAddr()`), combined with `X-Forwarded-For` only under your trusted-proxy setting. A raw forwarded header is never trusted without one. Keep the container's own forwarded-header handling off (Spring Boot: `server.forward-headers-strategy=none`, the default) — a `RemoteIpValve` would rewrite the peer before camada judges the header.
3. Runs your ordered custom rules, then the allow, block and challenge lists. A blocked request gets **403** with `x-block-reason` (and `x-block-rule` when a rule decided) before your app runs. The event still ships, marked as an SDK block so it is not confused with your own 403s.
4. A challenged browser gets the proof-of-work page; an API call reaches your app, and its response carries `x-camada-challenge: 1` for your frontend to act on. A request whose IP cannot be resolved is let through. See [challenge](https://www.camada.app/docs/challenge).
5. Serves the beacon endpoints (a body camada already read is replayed to the app whole), then runs your app with `x-rid` and the `_sfp` session cookie on its response — stamped before the chain runs, so your handlers see them too — and when the response is done, or in `AsyncListener.onComplete` when the app went async, ships one batched, redacted event with its real status, latency and the Spring MVC route pattern. An exception in your app ships as `st: 500` and propagates unchanged. The filter handles the original `REQUEST` dispatch only; `ASYNC`, `ERROR`, `FORWARD` and `INCLUDE` pass through.

## Add the beacon

Bots that never run JavaScript are the cheapest to catch. Put the tag in the `<head>` of the pages you render and the filter does the rest:

beacon

```java
// PageController.java, beside CamadaConfig.java: the tag goes in the <head> of the pages you render
package com.example.demo;   // your application's package

import dev.camada.Camada;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class PageController {
  @GetMapping(value = "/", produces = "text/html")
  String home(HttpServletRequest request) {
    return "<html><head>" + Camada.scriptTag(request) + "</head><body>…</body></html>";
  }
}
```

`Camada.scriptTag(request)` (the `HttpServletRequest`) returns a `<script src="/_cam/b.js?r=…" async>` tag carrying this request's event id, so the beacon is joined to the page view it came from. The filter serves the script at `GET /_cam/b.js` (vendored into the jar, cacheable for an hour) and relays `POST /_cam/fp` onto the event batch, stamped with the client address camada resolved — never the one the body claims. Both endpoints sit behind the verdict, so a blocked client gets 403 there too. Turn the beacon off in the project's settings and the tag becomes empty and both endpoints fall through to your app.

## Report what your app knows

The wire shows a `POST /login`; only your handler knows whether it failed. Tell camada from the handler:

outcomes

```java
// PageController.java: these imports too…
import java.net.URI;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

// …and this handler in the class
@PostMapping("/login")
ResponseEntity<?> login(HttpServletRequest request, @RequestParam String email, @RequestParam String password) {
  if (!signIn(email, password)) {
    Camada.track(request, "login_failed", email);   // hashed in-process; never throws
    return ResponseEntity.status(401).body(Map.of("error", "invalid"));
  }
  return ResponseEntity.status(303).location(URI.create("/")).build();
}

private boolean signIn(String email, String password) {
  return false;   // your own check
}
```

`Camada.track(request, event, user)` ships the outcome joined to this request's event and session. The user identifier is hashed in-process with your ingest token before it is queued — the raw value never leaves the process. It never throws and is a no-op on a request the filter did not run for. The event name is free-form; the analyst's rules read this vocabulary:

| Event | When |
| --- | --- |
| `login_failed / login_succeeded` | a credential check settled |
| `signup` | an account was created |
| `password_reset` | a reset was requested |
| `mfa_failed` | a second factor was rejected |
| `payment_failed / payment_succeeded` | a charge settled |
| `coupon_failed` | a promo code was rejected |

A route you want to gate yourself can serve the challenge on demand; the per-request `Context` (`rid`, `sid`, `ip`) is the `"camada"` request attribute:

gating a route

```java
// PageController.java: these imports too…
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

// …and this handler in the class
@GetMapping("/export")
void export(HttpServletRequest request, HttpServletResponse response) throws IOException {
  if (Camada.serveChallenge(request, response)) {
    return;   // the proof-of-work page (or 403 JSON) until this browser holds a valid _cch
  }
  response.getWriter().write("…");   // your export
}
```

## What this position sees

The same position as `@camada/node`, in a JVM: the final status and latency, the session, the beacon's browser signals, your app's outcomes, and the header order as the container hands it over. No ASN, country or TLS fingerprint reaches a servlet — camada knows what this tap can see from the position itself and never scores their absence as evidence against a request — so those conditions fail open in-app and the analyst resolves ASN and country itself. `matches` patterns are JS regexes read by `java.util.regex`: named groups are translated (a plain group, `\k<name>` its number), `[^]` and a bare `$` too, and a spelling the engine still rejects never matches here while it does at the edge. What a rule can key on is on [rules and list entries](https://www.camada.app/docs/rules).

## Deploying it

- Every JVM polls its own snapshot (about 5 MB resident) and flushes its own batches; the tenant's `poll_seconds` keeps the cadence honest across a fleet. Both threads are daemons: they never keep a JVM alive.
- Pending events drain at exit within half a second, from a JVM shutdown hook — which runs on SIGTERM as well as on a normal exit, so a container stopped by its orchestrator keeps its last batch. No signal handlers are installed; a JVM does not fork, so there is no fork handling to speak of.
- Serverless hosts: `CAMADA_SERVERLESS=1`. A cold invocation fails open and catches up on the next one.
- Not in this release: a Spring Boot starter (the filter is plain `jakarta.servlet`, registered by hand as above) and container-specific suites — Jetty and Undertow take the same filter but are not exercised.

## It fails open

Without a key the filter is **inert**: one log line, no requests, no enforcement, so an unprovisioned environment behaves exactly as if camada were not installed. Beyond that, a dead ingest (logged at most once a minute on the `camada` `java.util.logging` logger, message only, never a stack trace), a corrupt snapshot or a bug in the package costs you the telemetry, never the request — camada can never turn one of your responses into a 5xx. The whole stance is on the [security](https://www.camada.app/security) page.

## Sitemap

[Every page on this site](https://www.camada.app/sitemap.md)
