Integrations

Java

Pure-JVM AI agent detection for the JDK HttpServer and Servlet containers

Overview

Checkpoint for Java embeds the same detection engine the .NET SDK and the DNS gateway run, compiled to WebAssembly, and executes it through Chicory, a pure-Java WebAssembly runtime. There are no native dependencies, and it requires JDK 21 or later.

ModulePurpose
checkpoint-coreEngine host (C-ABI over WASI), detection API, dashboard policy and telemetry client, enforcement planner
checkpoint-jdk-httpservercom.sun.net.httpserver.Filter adapter
checkpoint-servletjakarta.servlet.Filter adapter for servlet containers (Spring Boot, Tomcat, Jetty)

Every request is classified by the embedded engine, producing identical verdicts to the DNS gateway and the .NET and TypeScript SDKs (a cross-runtime parity check asserts this on every pull request), and detections report to the dashboard's /api/v1/log-detection with sdk.runtime: "jvm".

With the CLI

The CLI prints the Java dependencies and the browser posture settings for your project. Sign in and link the repository first with kya-os setup. Then, from the root of your Java service (next to pom.xml or build.gradle), run:

kya-os detect install --surface sdk

It prints:

  • The Maven or Gradle dependencies for the latest Java SDK release: checkpoint-core and checkpoint-servlet. For an app on com.sun.net.httpserver, it tells you to use checkpoint-jdk-httpserver in place of checkpoint-servlet.
  • A BrowserPostureOptions block with your project's public key pinned, and a CheckpointOptions builder that uses it.
  • A note that the SDK reads CHECKPOINT_PROJECT_ID and CHECKPOINT_API_KEY from the environment when the builder leaves them unset.

The CLI does not edit your pom.xml, your Gradle files or your Java code: you apply what it prints. If the project has no usable page origin, or the CLI can't confirm the key, it leaves the BrowserPostureOptions block out and says why on a missing: line. Java verifies posture tokens locally with the pinned key. After you rotate the key, run the command again and update the key values in your configuration.

For the beacon on your pages, see Set up browser posture. To add the dependencies yourself, continue with Installation.

Prerequisites

  • A Checkpoint project: get your Project ID and API key from Installations in the dashboard.
  • JDK 21 or later.
  • A com.sun.net.httpserver based app, or a jakarta.servlet container.

Installation

All three modules are on Maven Central. Add checkpoint-core and the adapter for your server; the adapter below is for a servlet container.

<dependency>
  <groupId>ai.knowthat.checkpoint</groupId>
  <artifactId>checkpoint-core</artifactId>
  <version>0.4.1</version>
</dependency>
<dependency>
  <groupId>ai.knowthat.checkpoint</groupId>
  <artifactId>checkpoint-servlet</artifactId>
  <version>0.4.1</version>
</dependency>

For an app built on the JDK's com.sun.net.httpserver, use checkpoint-jdk-httpserver in place of checkpoint-servlet. The servlet API itself is provided: your container supplies it.

The thin jars resolve Chicory, ASM and Jackson onto your classpath. If your app already carries a different Jackson version, either pin Jackson through your own dependency management or use the adapter's shaded jar in place of both dependencies above. It contains the adapter, checkpoint-core and those libraries relocated under ai.knowthat.checkpoint.shaded.*, so it needs nothing else: exclude the transitive dependencies its pom still lists.

<dependency>
  <groupId>ai.knowthat.checkpoint</groupId>
  <artifactId>checkpoint-servlet</artifactId>
  <version>0.4.1</version>
  <classifier>shaded</classifier>
  <exclusions>
    <exclusion>
      <groupId>*</groupId>
      <artifactId>*</artifactId>
    </exclusion>
  </exclusions>
</dependency>

Quick Setup

Checkpoint checkpoint = Checkpoint.create(CheckpointOptions.builder()
    .projectId("<project-id>")
    .apiKey("sk_live_...")
    // The builder default is OBSERVE, which never blocks. Set ENFORCE to
    // actually short-circuit.
    .enforcementMode(CheckpointOptions.EnforcementMode.ENFORCE)
    .onAgentDetected(EnforcementAction.BLOCK)
    .build());

// jdk.httpserver:
context.getFilters().add(CheckpointFilter.wrap(checkpoint));
// servlet containers:
new CheckpointServletFilter(checkpoint);

Call checkpoint.warmup() during your app's own startup to pre-warm the engine pool off the request path; both adapters already call it synchronously during their own init, so size your container's health-check timeout accordingly.

Enforce vs Observe Mode

enforcementMode defaults to OBSERVE, which never short-circuits a request: whatever onAgentDetected (or the dashboard policy) would have done, the request passes through and gets the same response a browser gets. The telemetry event reports enforcement.action: "log" with the withheld action in enforcement.wouldBlock. Set ENFORCE to actually block, redirect, instruct, or challenge.

onAgentDetected defaults to EnforcementAction.LOG and confidenceThreshold defaults to 70.

Client IP: Trust Profiles

The client IP the engine sees is the direct TCP peer by default, which is the load balancer's own address once anything sits in front of the app: vendor IP verification then checks the load balancer's address instead of the caller's. Both adapters ship the same Direct/Vercel/Cloudflare/TrustedProxy header-precedence profiles the TypeScript and .NET SDKs have:

  • checkpoint-servlet: CheckpointServletFilter's two-argument constructor takes a ClientIpTrustPolicy, or configure it via the clientIpPolicy (vercel / cloudflare / trusted-proxy) and clientIpTrustedHops (hop count for trusted-proxy) filter init-params. Default stays directPeer().
  • checkpoint-jdk-httpserver: RemoteIpResolver.vercel(), .cloudflare(), and .trustedProxy(int hops) factories, passed to CheckpointFilter.create / .wrap the same way directPeer() always was. A fifth, Checkpoint-specific profile, RemoteIpResolver.gateway(), trusts the gateway's KYA-Client-IP header for an origin deployed directly behind the Checkpoint Cloudflare gateway; it additionally verifies the direct peer against Cloudflare's published edge IPv4 ranges before trusting the header at all.

Every profile is opt-in, and none of them is safe to select on an origin any untrusted caller can also reach directly. Either adapter also accepts a fully custom RemoteIpResolver / ClientIpResolver for a topology none of the built-in profiles cover.

Response Headers

No response tells the caller how the request was classified. A pass-through carries one header:

HeaderValueWhen
KYA-Correlation-Id32-hex correlation idAlways

An enforcement response adds only the headers its protocol needs: Retry-After and Cache-Control on a 503, Location on a redirect, the KYA-Auth-*, KYA-Action, Link and WWW-Authenticate headers on instruct and challenge, and KYA-Block-Reason on a kyaos/* credential reject. Classification, confidence, verification method and reason codes are available to your code through EnforcementResponse.detectionResult(), never on the wire. The detected agent's name appears only in the ?agent=<name> query of the redirect and instruct URLs, where the connect page reads it to show that agent's install steps.

If a burst of traffic exhausts the bounded WASM engine pool (maxEngineInstances, engineAcquireTimeout), the request degrades to detection-unavailable rather than blocking on a cold instantiation. Under failOpen (the default) the request still passes through; with failOpen(false), or when the request presents a credential, it gets a 503.

Signature and Delegation Verification

ChatGPT's RFC 9421 HTTP Message Signatures are verified whenever a request carries Signature / Signature-Input headers: the SDK reconstructs the signature base and verifies it with the JDK's native Ed25519 support against a live-fetched public key, no configuration required. A verified signature yields 100% confidence.

A kya-http/2 signed request (a kya member in Signature-Input or Signature) is verified against its exact body and its full request target, query string included. Both adapters read the body of a signed request, up to 1 MiB, before your handler runs, then hand your handler the same bytes: the servlet filter wraps the request (getInputStream(), getReader(), and the form fields of an application/x-www-form-urlencoded POST, but not getParts() for a multipart body), and the jdk-httpserver filter replaces the exchange's request body stream. In enforce mode, a signed body over 1 MiB is answered 413 with Cache-Control: no-store and never reaches detection or your handler, as in the .NET SDK; a Content-Length over 1 MiB is refused before any of the body is buffered. Observe mode never refuses a request: your handler gets an oversized signed body exactly as sent, its signature is not verified, and detection reports it as signed_body_too_large. Unsigned requests are untouched: their bodies are never read.

In a servlet container, register CheckpointServletFilter before any filter that reads request parameters or the request body. On a form POST, reading a parameter makes the container read the whole body. The filter then reads an empty body, and a genuine signed POST is refused as kyaos/invalid-signature. The usual culprit is Spring Security, whose form login reads the username and password parameters. Spring Security's filter chain runs at order -100, so give the Checkpoint filter a lower order:

@Bean
FilterRegistrationBean<CheckpointServletFilter> checkpointFilter(Checkpoint checkpoint) {
    FilterRegistrationBean<CheckpointServletFilter> registration =
        new FilterRegistrationBean<>(new CheckpointServletFilter(checkpoint));
    // Ahead of Spring Security (-100) and every other filter that reads
    // parameters; only Spring's character-encoding filter runs earlier.
    registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 1);
    return registration;
}

In web.xml, put the Checkpoint <filter-mapping> first: the container runs filters in the order their mappings appear.

Separately, a request that presents a delegation credential (Authorization: Bearer <VC-JWT>, or the KYA-Delegation marker header for hosts that strip Authorization) with no kya-http/2 signature routes to the engine's standalone delegation-verify entry instead of plain detection.

By default (CheckpointOptions.Resolution.ENGINE_DIRECTED), the SDK fetches the DID documents and status lists the engine names, in the background, from public HTTPS hosts only, and answers each request from its cache. Your server needs outbound HTTPS to the trusted delegation roots and to the did:web hosts of the agents and issuers you accept. A request whose document or status list has not been fetched yet is answered kyaos/identity-pending or kyaos/status-pending, 503 with Retry-After: 1. One whose fetch failed is decided by the engine, for example kyaos/did-unresolvable when the signer's document returns 404.

On a host with no outbound network access, set .resolution(CheckpointOptions.Resolution.STATIC). Nothing is fetched, and the engine sees only the DID documents and revocation lists you supply in trustedDelegationDidDocuments and trustedDelegationRevokedIndices. Those two maps are read only in that mode: with the default resolution, build() throws an IllegalStateException when either is set.

Dashboard Reporting

Detection events are posted to POST /api/v1/log-detection as a batch, fire-and-forget after the request completes; a request is reported when the SDK detects an agent or bot or applies a policy verdict, when its posture outcome is absent or rejected on a listed path, and for the first rejected posture token of each reason (later rejections of that reason are counted and sent with the next report). .telemetryUndetectedSampleRate(fraction) (0.0 to 1.0, default 0.0) also reports that share of undetected requests; set it to fill the Verified and No token sent counts for ordinary visitors in Posture health. The first verdict after startup, and the first after each flush, is sent immediately. Later verdicts are buffered until telemetryBatchSize (default 200, at most 500) is reached, 2 seconds pass since a verdict was queued, you call Checkpoint.flush(), or the Checkpoint closes; each Checkpoint also registers a JVM shutdown hook until it is closed, so create one per application and close it. The SDK keeps a report until the dashboard acknowledges it. A batch that fails with a transport error, a timeout (telemetryRequestTimeout, default 3 seconds), or any status other than 2xx, 400, 413 and 422 (401, 403, 404, 429 and every 5xx included) is retried with the same body and eventIds after an exponential backoff with jitter, and a Retry-After header raises the wait. A batch the dashboard rejects with 400, 413 or 422 is split in half until only the bad record is left, and that record alone is refused and kept in rejected.log in the spool directory. When more than telemetryQueueCapacity events (default 10,000) are waiting, or when close() is half a second from the end of its telemetryShutdownGrace (default 5 seconds; a grace under one second spills at its halfway point), reports are written to a bounded disk spool (telemetrySpoolDirectory, telemetrySpoolMaxBytes, default 512 MiB; the SDK adds a subfolder named from the project id and base URL, creates it owner-only, and expects a local disk that survives restarts) and sent in order on the next start or flush. Several JVMs on one host use separate slot folders inside the spool directory, and a JVM takes over the rows of any slot whose owner has exited, when it starts and on its later flush passes. Each slot records how far it has sent, so a restart or a takeover continues after the rows already delivered; if that record is lost the segment is sent again, and the dashboard stores a repeated record once. Checkpoint.reportStats() returns delivered, spilled, replayed, dropped, refused and discarded counts, and a warning with the counts is logged at most once a minute. .telemetryFlushInterval(...) is deprecated and has no effect: the delay is a fixed 2 seconds. Requires projectId and apiKey to be set: without them, detection and enforcement still run, only the dashboard hop is skipped. Raw IP and full URLs are reported by default; .telemetryHashIp(true) on the builder replaces the IP with its network prefix (the /24 for IPv4, the /48 for IPv6, for example 203.0.113.0) before it leaves the process. The report marks the prefix with context.ipPrefixLength (24 or 48); ingest stores it in metadata and skips exact-IP enrichment and range corroboration. .telemetryStripQuery(true) strips the ?query suffix from reported paths and URLs. Under an edgeTrustPolicy of cloudflare() or trustedProxy(n) with n > 0, the kya-edge-ja4 header value is also reported as transport.ja4; with no edge policy the header is never read and the field is omitted. A request that carries no session header or cookie reports its context.browserSessionId as a keyed hash of its guest Authorization: Bearer token (with context.sessionSource set to guest-token); the token itself is never sent.

Browser posture outcome

With browser posture configured, the filters verify the posture token once per request. A handler reads that outcome instead of verifying the token again: the request attribute PostureOutcome.REQUEST_ATTRIBUTE (ai.knowthat.checkpoint.postureOutcome) with CheckpointServletFilter, and CheckpointFilter.postureOutcome(exchange) with CheckpointFilter.

A token can also carry a detection claim: the beacon's own final verdict, {class: "ai_agent" | "bot", name, confidence}. When the SDK's baseline is Human or IncompleteData, the claim classifies the request as that class with that name and confidence (an AI agent or bot, isAgent true), and it wins over the automation flags and the agent-driven verdict. An existing agent or bot verdict is never downgraded. A present but malformed claim (a class other than ai_agent or bot, a name outside 1 to 64 characters or with control characters, a confidence that is not an integer from 0 to 100) rejects the whole token as INVALID_CLAIMS. A disclosed navigator.webdriver now also sets isAgent true on its Bot classification, matching the TypeScript SDK.

Report requests that carry no posture token

An agent that calls your API directly runs no page, so it sends no posture token. If nothing else flags it, the SDK does not report it. A list of paths closes that gap. When a request on a listed path has a posture outcome of absent or rejected, the SDK reports it with the outcome, IP address, user agent, correlation id and trusted-edge JA4, even when it detected no agent. A request with a verified outcome is not reported for this reason.

Checkpoint stores the list as the project setting postureReportPaths and the SDK reads it with the policy it already fetches, so you change coverage without a code change. Set or change it in the dashboard under your project's Settings, in the Posture report paths field. A project has up to 50 paths. Each path starts with /. /api/orders matches /api/orders and /api/orders/42, but not /api/orders2. * matches inside one path segment and ** matches across segments. A pattern with * or ** is matched against at most the first 2,048 characters of a path, and a longer path does not match it. A project with no paths reports exactly what it reported before, except that while the first policy fetch is slow, requests held past postureReportHoldCapacity are reported at once.

The rule adds reports only. It never blocks, redirects or challenges a request, in OBSERVE and ENFORCE alike. It needs projectId, apiKey and browserPosture, because without browserPosture a request has no outcome. The SDK learns the list from its first policy fetch, which Checkpoint.create starts. A request that arrives before that fetch completes is held (up to postureReportHoldCapacity, default 10,000, then the oldest is reported at once without the path check and counted) and reported once the list arrives if its path matches. If the fetch fails, the held requests are released unreported and counted.

To list the reported requests, call GET /api/v1/projects/{id}/detections?view=extended&postureOutcome=absent&startDate=<ISO date>. postureOutcome is verified, absent or rejected, and it requires startDate. The window from startDate to endDate (default: now) can span at most 7 days. Each item carries postureOutcome with its status and, for rejected, its reason.

Block responses

A block the SDK decides gets a JSON body at blockStatusCode (default 403). The SDK decides the blocks that come from the configured onAgentDetected action, the dashboard policy and browser posture. A dashboard deny list, allow list or confidence threshold that replaces an engine block counts as one of these. To answer those in plain text instead, set .responseFormat(CheckpointOptions.AgentResponseFormat.PLAIN_TEXT). An ordinary agent block then returns 200, since AI fetchers tend to drop the body of a 4xx response, with a plain-text AI Agent Notice that points to your redirectUrl (your MCP server) when that is an absolute URL.

A block the engine decides, such as a presented credential that fails verification or an engine policy block, keeps the status the engine renders in either format. That means the kya-os/http status for its kyaos/* code (for example 401 for kyaos/did-unresolvable, or 429 with Retry-After: 1 for kyaos/replay-capacity), and the Block reason's status otherwise. The body carries only error (the kyaos/* code for a credential reject) and timestamp, the same two fields a block the SDK decides has. The engine's reason, pattern and confidence stay on EnforcementResponse.detectionResult(), and no X-Checkpoint-* header is sent. A kyaos/* code is also sent in the KYA-Block-Reason header.

Next Steps