Skip to content

Spring Boot

This guide adds Bunkhouse to a server-rendered Spring Boot application (Spring MVC, Spring Security, Thymeleaf). Every file shown below is part of a runnable example app that is tested end to end against the real Bunkhouse API and embed.

Framework Spring Boot 4.1.2 (Spring Framework 7.0.10, Spring Security 7.1.2): Spring MVC controllers, form login with HTTP sessions, Thymeleaf 3.1.5 templates
Runtime Java 25 (25.0.4.1 in the tested eclipse-temurin:25-jre image), embedded Tomcat 11.0.27, built with Maven
Example apps/docs/integrations/spring-boot/ in the Bunkhouse repository

You need:

  • Your application’s exact origin registered on the site (for example https://app.example.com).
  • The site’s public key (pk_...).
  • For identity tokens (recommended): a signing secret (ss_...) and its key ID from the site’s Keys page.
  • For server mint instead: a mint key (sk_...).
  • Existing sign-in through Spring Security. The example uses form login with two demo users; your own authentication takes its place. Bunkhouse only needs one thing from it: the signed-in user’s stable ID on the server.
File Purpose
src/main/java/example/AgentConfig.java Validates the public Bunkhouse configuration at startup
src/main/java/example/AgentController.java GET /api/agent-identity and POST /api/agent-session
src/main/java/example/SecurityConfig.java Mint-endpoint CSRF exemption and 401 entry point for the Bunkhouse endpoints
src/main/java/example/ContentSecurityPolicyFilter.java Your Content-Security-Policy with the Bunkhouse sources added
src/main/resources/templates/agent-embed-*.html The script tags for one installation mode (Thymeleaf fragments)
src/main/resources/static/agent-identity.js, agent-auth-headers.js Page hooks, loaded before the loader

Set the server environment. Only AGENT_PUBLIC_KEY and the origins ever reach the browser:

Terminal window
AGENT_PUBLIC_KEY=pk_...
AGENT_SIGNING_SECRET=ss_... # server only
AGENT_SIGNING_KID=... # key ID shown with the signing secret
AGENT_ALLOWED_ORIGINS='["https://app.example.com"]'
# AGENT_API_BASE and AGENT_EMBED_ORIGIN default to https://api.bunkhouse.ai and https://embed.bunkhouse.ai
src/main/java/example/AgentConfig.java
package example;
import java.net.URI;
import java.util.List;
import java.util.Objects;
import org.springframework.stereotype.Component;
import tools.jackson.databind.json.JsonMapper;
/**
* Bunkhouse public configuration, validated once at startup. Signing and mint credentials are read only
* by the endpoints that use them, so each mode needs only its own secret.
*/
@Component
public class AgentConfig {
private final String publicKey;
private final List<String> allowedOrigins;
private final String apiBase;
private final String embedOrigin;
public AgentConfig(JsonMapper json) {
publicKey = Objects.requireNonNullElse(System.getenv("AGENT_PUBLIC_KEY"), "");
if (!publicKey.startsWith("pk_")) throw new IllegalStateException("AGENT_PUBLIC_KEY must be the site's public key (pk_...)");
String[] origins = json.readValue(Objects.requireNonNullElse(System.getenv("AGENT_ALLOWED_ORIGINS"), "[]"), String[].class);
if (origins.length == 0) throw new IllegalStateException("AGENT_ALLOWED_ORIGINS must be a JSON array such as [\"https://app.example.com\"]");
allowedOrigins = List.of(origins).stream().map(o -> exactOrigin("AGENT_ALLOWED_ORIGINS", o)).toList();
apiBase = exactOrigin("AGENT_API_BASE", Objects.requireNonNullElse(System.getenv("AGENT_API_BASE"), "https://api.bunkhouse.ai"));
embedOrigin = exactOrigin("AGENT_EMBED_ORIGIN", Objects.requireNonNullElse(System.getenv("AGENT_EMBED_ORIGIN"), "https://embed.bunkhouse.ai"));
}
private static String exactOrigin(String name, String value) {
URI uri = URI.create(value);
boolean web = "https".equals(uri.getScheme()) || "http".equals(uri.getScheme());
if (!web || uri.getRawUserInfo() != null || !value.equals(uri.getScheme() + "://" + uri.getRawAuthority())) {
throw new IllegalStateException(name + " must be an exact origin like https://app.example.com");
}
return value;
}
public String publicKey() { return publicKey; }
public List<String> allowedOrigins() { return allowedOrigins; }
public String apiBase() { return apiBase; }
public String embedOrigin() { return embedOrigin; }
}

The controller signs a ten-minute HS256 token with javax.crypto.Mac for the user Spring Security already authenticated. It rejects requests whose Sec-Fetch-Site is not same-origin (403), signed-out users (401, never a login redirect) and other methods (405 with Allow), and marks every response Cache-Control: no-store. The subject comes only from the security context, never from the query, body or headers. The same class contains the server mint endpoint used by the alternative below.

src/main/java/example/AgentController.java
package example;
import static java.nio.charset.StandardCharsets.UTF_8;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.GeneralSecurityException;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tools.jackson.databind.json.JsonMapper;
/**
* Bunkhouse endpoints: GET /api/agent-identity (identity token mode) and POST /api/agent-session (server
* mint mode). Both vouch only for the user Spring Security already authenticated: the
* {@code @AuthenticationPrincipal} parameter is the seam to your existing authentication.
*/
@RestController
public class AgentController {
private static final Logger log = LoggerFactory.getLogger(AgentController.class);
// HTTP/1.1: no h2c upgrade attempt when AGENT_API_BASE is plain http.
private final HttpClient http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).connectTimeout(Duration.ofSeconds(5)).build();
private final AgentConfig agent;
private final JsonMapper json;
public AgentController(AgentConfig agent, JsonMapper json) {
this.agent = agent;
this.json = json;
}
/** HS256 identity token: kid header, sub = stable opaque user ID, aud = public key, 10 minutes. */
String signIdentityToken(String userId, String secret, String kid) throws GeneralSecurityException {
long iat = Instant.now().getEpochSecond();
Base64.Encoder base64url = Base64.getUrlEncoder().withoutPadding();
String header = base64url.encodeToString(json.writeValueAsBytes(Map.of("alg", "HS256", "typ", "JWT", "kid", kid)));
String payload = base64url.encodeToString(json.writeValueAsBytes(Map.of("sub", userId, "aud", agent.publicKey(), "iat", iat, "exp", iat + 600)));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(UTF_8), "HmacSHA256"));
return header + "." + payload + "." + base64url.encodeToString(mac.doFinal((header + "." + payload).getBytes(UTF_8)));
}
@GetMapping("/api/agent-identity")
ResponseEntity<?> identity(@RequestHeader(name = "Sec-Fetch-Site", required = false) String fetchSite,
@AuthenticationPrincipal AppUser user) throws GeneralSecurityException {
if (!"same-origin".equals(fetchSite)) return error(403, "forbidden");
if (user == null) return error(401, "unauthorized");
String secret = System.getenv("AGENT_SIGNING_SECRET"), kid = System.getenv("AGENT_SIGNING_KID");
if (secret == null || secret.isEmpty()) return notConfigured("AGENT_SIGNING_SECRET");
if (kid == null || kid.isEmpty()) return notConfigured("AGENT_SIGNING_KID");
return noStore(200).body(Map.of("identityToken", signIdentityToken(user.id(), secret, kid)));
}
@PostMapping("/api/agent-session")
ResponseEntity<?> session(@RequestHeader(name = "Origin", required = false) String origin,
@RequestHeader(name = "X-Agent-Embed", required = false) String embed,
@RequestHeader(name = "Sec-Fetch-Site", required = false) String fetchSite,
@AuthenticationPrincipal AppUser user) throws InterruptedException {
// The embed's POST carries no CSRF token; these three checks are this route's CSRF defense.
if (origin == null || !agent.allowedOrigins().contains(origin) || !"1".equals(embed) || !"same-origin".equals(fetchSite)) {
return error(403, "forbidden");
}
if (user == null) return error(401, "unauthorized");
String key = System.getenv("AGENT_EMBED_SECRET_KEY");
if (key == null || key.isEmpty()) return notConfigured("AGENT_EMBED_SECRET_KEY");
HttpRequest request = HttpRequest.newBuilder(URI.create(agent.apiBase() + "/v1/embed/sessions"))
.timeout(Duration.ofSeconds(15))
.header("Authorization", "Bearer " + key)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofByteArray(json.writeValueAsBytes(
Map.of("publicKey", agent.publicKey(), "mode", "signed", "userId", user.id(), "origin", origin))))
.build();
try {
HttpResponse<byte[]> upstream = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
String type = upstream.headers().firstValue("content-type").orElse("application/json");
return noStore(upstream.statusCode()).contentType(MediaType.parseMediaType(type)).body(upstream.body());
} catch (IOException e) {
return error(502, "session_service_unavailable");
}
}
// Every other method: 405 with Allow (the method-specific mappings above win for their method).
@RequestMapping("/api/agent-identity")
ResponseEntity<?> identityMethods() { return methodNotAllowed("GET"); }
@RequestMapping("/api/agent-session")
ResponseEntity<?> sessionMethods() { return methodNotAllowed("POST"); }
private static ResponseEntity.BodyBuilder noStore(int status) {
return ResponseEntity.status(status).cacheControl(CacheControl.noStore());
}
private static ResponseEntity<?> error(int status, String code) {
return noStore(status).contentType(MediaType.APPLICATION_JSON).body(Map.of("error", code));
}
private static ResponseEntity<?> methodNotAllowed(String allow) {
return noStore(405).header(HttpHeaders.ALLOW, allow).body(Map.of("error", "method_not_allowed"));
}
private static ResponseEntity<?> notConfigured(String variable) {
log.error("Bunkhouse: {} is not set", variable);
return error(503, "not_configured");
}
}

The @AuthenticationPrincipal parameter is the seam to your authentication. Spring resolves it to null for signed-out (anonymous) requests. Replace AppUser with your own principal type and pass its stable, opaque ID:

src/main/java/example/AppUser.java
package example;
import java.util.Collection;
import java.util.List;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
/**
* The example's signed-in principal. {@code id} is the stable, opaque user ID sent to Bunkhouse (never
* the username or an email address); in your app this is your own principal type.
*/
public record AppUser(String id, String username, String passwordHash) implements UserDetails {
@Override public String getUsername() { return username; }
@Override public String getPassword() { return passwordHash; }
@Override public Collection<? extends GrantedAuthority> getAuthorities() { return List.of(); }
}

In your SecurityFilterChain, exempt only the mint endpoint from CSRF and answer signed-out API requests with 401 instead of a redirect to your sign-in page:

src/main/java/example/SecurityConfig.java (excerpt)
// CSRF stays on everywhere except the Bunkhouse mint endpoint: the embed's mint POST carries no
// CSRF token (its Origin, X-Agent-Embed and Fetch Metadata checks replace it).
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/agent-session"))
// Signed-out API requests get 401, never a redirect to the sign-in page.
.exceptionHandling(exceptions -> exceptions.defaultAuthenticationEntryPointFor(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), PathPatternRequestMatcher.withDefaults().matcher("/api/**")))

In the page head, load both page hooks and then the loader, before your application’s own scripts: the identity hook, then the getAuthHeaders hook (see Auth headers), then embed.js, so window.AgentEmbedConfig is complete when the loader runs. The hooks are external same-origin scripts, and every tag carries the response’s CSP nonce:

src/main/resources/templates/agent-embed-identity.html
<!--/* Identity token mode (recommended): both page hooks first, then the loader, before the app's scripts. */-->
<th:block th:fragment="tags">
<script src="/agent-identity.js" th:nonce="${cspNonce}"></script>
<script src="/agent-auth-headers.js" th:nonce="${cspNonce}"></script>
<script th:src="|${agent.embedOrigin()}/embed.js|" th:nonce="${cspNonce}"
th:data-public-key="${agent.publicKey()}" data-hints-url="/agent-hints.json"></script>
</th:block>
src/main/resources/static/agent-identity.js
// Identity token mode: the embed calls this hook whenever it starts a session for the signed-in user.
window.AgentEmbedConfig = {
...window.AgentEmbedConfig,
getIdentityToken: async () => {
const res = await fetch("/api/agent-identity", { credentials: "same-origin", cache: "no-store" });
if (!res.ok) throw new Error("Not signed in");
return (await res.json()).identityToken;
},
};
src/main/resources/templates/layout.html (excerpt)
<meta name="_csrf" th:content="${_csrf.token}">
<meta name="_csrf_header" th:content="${_csrf.headerName}">
<th:block th:replace="~{agent-embed-__${mode}__ :: tags}"></th:block>
<!--/* The application's own JavaScript comes after the loader. */-->
<script src="/app.js" th:nonce="${cspNonce}" defer></script>

The example layout includes one of the three agent-embed-* fragments, selected by a test-only ?mode= switch. Your application uses exactly one.

Instead of the identity hook, add data-session-endpoint to the loader. Do not keep agent-identity.js on the page: when getIdentityToken is defined it takes precedence.

src/main/resources/templates/agent-embed-mint.html
<!--/* Server mint mode: no identity hook; the embed posts to the same-origin session endpoint. */-->
<th:block th:fragment="tags">
<script src="/agent-auth-headers.js" th:nonce="${cspNonce}"></script>
<script th:src="|${agent.embedOrigin()}/embed.js|" th:nonce="${cspNonce}"
th:data-public-key="${agent.publicKey()}" data-hints-url="/agent-hints.json"
data-session-endpoint="/api/agent-session"></script>
</th:block>

POST /api/agent-session in AgentController.java above accepts only requests with an Origin from AGENT_ALLOWED_ORIGINS, X-Agent-Embed: 1 and Sec-Fetch-Site: same-origin (403 otherwise), authenticates the session (401), then creates the session with the mint key through java.net.http.HttpClient and returns the API’s status and body unchanged with Cache-Control: no-store. If the API is unreachable it returns 502 without details. Set AGENT_EMBED_SECRET_KEY=sk_... on the server; the identity endpoint does not need it.

Spring Security’s CSRF protection stays on for every other route. The embed’s POST carries no CSRF token, so /api/agent-session is exempted in SecurityConfig.java above, and its three header checks are that route’s protection. /api/agent-identity keeps CSRF protection, so a wrong-method POST may get Spring Security’s CSRF 403 instead of the 405, which is fine.

Anonymous sessions need neither the identity hook nor a session endpoint: the fragment keeps only the getAuthHeaders hook and the loader. Enable anonymous access for the site in the dashboard first and set its monthly token and spend limits; usage is charged to your account’s credit and stops at a limit. Anyone who can load your page can start one, so it is not proof of identity.

src/main/resources/templates/agent-embed-anonymous.html
<!--/* Anonymous mode (enable it for the site first): the auth-headers hook, then the loader. */-->
<th:block th:fragment="tags">
<script src="/agent-auth-headers.js" th:nonce="${cspNonce}"></script>
<script th:src="|${agent.embedOrigin()}/embed.js|" th:nonce="${cspNonce}"
th:data-public-key="${agent.publicKey()}" data-hints-url="/agent-hints.json"></script>
</th:block>

The example signs in and out with Spring Security’s form login and logout: ordinary form posts (with the _csrf field) that redirect back to the same page. Every account change therefore loads a new document, and a new document starts a new embed with no session, so the previous user’s session can never carry over. Spring Security changes the session ID on login, and the session cookie is HttpOnly and SameSite:

src/main/java/example/SecurityConfig.java (excerpt)
// Demo sign-in and sign-out: full-document form posts that return to the same page (and ?mode).
.formLogin(form -> form
.loginPage("/")
.loginProcessingUrl("/login")
.successHandler((req, res, auth) -> res.sendRedirect(safeNext(req)))
.failureHandler((req, res, e) -> plain(res, 401, "Invalid username or password")))
.logout(logout -> logout.logoutSuccessHandler((req, res, auth) -> res.sendRedirect(safeNext(req))));
src/main/resources/application.properties
server.address=127.0.0.1
server.port=${PORT}
server.servlet.session.tracking-modes=cookie
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.same-site=lax
spring.main.banner-mode=off

If your application changes the signed-in user without a full page load (a fetch login, a single-page frontend), call window.AgentEmbed?.resetSession() right after the login, logout or account switch succeeds and before the new user’s requests. Render the loader once in the layout, not in per-page templates.

The assistant calls your API with the page’s cookies; supply any extra header your API requires with getAuthHeaders. Spring Security checks a CSRF token on every unsafe request. The layout renders the token and its header name (X-CSRF-TOKEN) into the _csrf and _csrf_header meta tags that Spring Security documents for JavaScript clients, and the hook returns them:

src/main/resources/static/agent-auth-headers.js
// Spring Security's CSRF token for this session (rendered into <meta> tags by the layout) for the
// assistant's calls to this app's API. Values never reach the model or the sandbox.
window.AgentEmbedConfig = {
...window.AgentEmbedConfig,
getAuthHeaders: () => {
const token = document.querySelector('meta[name="_csrf"]')?.content;
const header = document.querySelector('meta[name="_csrf_header"]')?.content;
return token && header ? { [header]: token } : {};
},
};

Each agent-embed-* fragment loads this hook before the loader. It merges into window.AgentEmbedConfig, so it keeps the identity hook’s getIdentityToken. Its values never reach the model or the sandbox.

The example keeps Spring Security’s default masked CSRF tokens, with one adjustment: Spring Security 7.1.2 answers a tampered token that unmasks to invalid UTF-8 with a 500 instead of a 403, so the example wraps the default handler to reject it as a wrong token:

src/main/java/example/SecurityConfig.java (excerpt)
/**
* Spring Security's default masked (BREACH-safe) CSRF token handling, except that a tampered token which
* unmasks to invalid UTF-8 counts as a wrong token (403); 7.1.2 answers it with a 500.
*/
private static final class MaskedCsrfTokenHandler implements CsrfTokenRequestHandler {
private final XorCsrfTokenRequestAttributeHandler delegate = new XorCsrfTokenRequestAttributeHandler();
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
delegate.handle(request, response, csrfToken);
}
@Override
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
try {
return delegate.resolveCsrfTokenValue(request, csrfToken);
} catch (IllegalArgumentException e) {
return null;
}
}
}

Send a stable, opaque user ID (the example’s fixture-alice), not the username or an email address.

Add the Bunkhouse sources to your existing policy rather than replacing it. Spring Security’s static contentSecurityPolicy header cannot carry a per-response nonce, so the example sets the policy in a servlet filter that also stores a fresh nonce as the cspNonce request attribute; the templates put it on their script tags with th:nonce. No 'unsafe-inline' or 'unsafe-eval' is needed:

src/main/java/example/ContentSecurityPolicyFilter.java
package example;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Base64;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* The app's existing Content-Security-Policy with the Bunkhouse sources added: the embed origin to
* script-src and frame-src, the API to connect-src. A fresh nonce per response (request attribute
* {@code cspNonce}) goes on the page's script tags.
*/
@Component
public class ContentSecurityPolicyFilter extends OncePerRequestFilter {
private final SecureRandom random = new SecureRandom();
private final AgentConfig agent;
public ContentSecurityPolicyFilter(AgentConfig agent) {
this.agent = agent;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
byte[] bytes = new byte[16];
random.nextBytes(bytes);
String nonce = Base64.getEncoder().encodeToString(bytes);
request.setAttribute("cspNonce", nonce);
response.setHeader("Content-Security-Policy", String.join("; ",
"default-src 'self'",
"script-src 'self' 'nonce-" + nonce + "' " + agent.embedOrigin(),
"style-src 'self' 'nonce-" + nonce + "'",
"frame-src " + agent.embedOrigin(),
"connect-src 'self' " + agent.apiBase(),
"img-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'"));
chain.doFilter(request, response);
}
}

From a checkout of the Bunkhouse repository, with Docker and local Supabase running:

Terminal window
node tests/framework-integrations/run.ts spring-boot

The run builds the example image, starts it against a fresh local site and drives the real pane in Chromium: identity, mint and anonymous sessions, endpoint guards, sign-out and account switches, discovery of the page’s startup request, a CSRF-protected write through getAuthHeaders, CSP and secret checks. In your own app, sign in and open the Assistant launcher: the first message creates a signed session.

Symptom Check
/api/agent-identity returns 401 The @AuthenticationPrincipal type does not match your principal (Spring then passes null), or the request does not carry the session cookie.
A login page instead of 401 Register the HttpStatusEntryPoint for /api/** in your SecurityFilterChain.
403 from either endpoint The request is cross-site or missing Sec-Fetch-Site; for mint, Origin must exactly match AGENT_ALLOWED_ORIGINS and the loader must carry data-session-endpoint. A 403 from Spring Security’s CSRF filter means /api/agent-session is not in ignoringRequestMatchers.
503 The signing secret, key ID or mint key for the mode you use is not set.
The pane reports a CSP block Add the embed origin to script-src and frame-src and the API to connect-src, and put the response’s nonce on the config and loader tags.
Startup requests are not discovered The page hooks and the loader must come before your application’s scripts in the head.
Two launchers Render the loader once, in the layout.
A secret in page source Only AGENT_PUBLIC_KEY and origins belong in templates; keep ss_ and sk_ values in server environment variables.