Axum
This guide adds Bunkhouse to a server-rendered Axum application. Every file shown below is part of a runnable example app that is tested end to end against the real Bunkhouse API and embed.
Tested setup
Section titled “Tested setup”| Framework | Axum 0.8.9 on Tokio, tower-sessions 0.15 (signed cookie, in-memory store), jsonwebtoken 11.1 (HS256), reqwest 0.13 (rustls) |
| Runtime | Stable Rust (rustc 1.99.1 in the tested image), release binary on Debian bookworm-slim |
| Example | apps/docs/integrations/axum/ 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. The example uses
tower-sessionswith 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/config.rs |
Validates the public Bunkhouse configuration at startup |
src/agent.rs |
GET /api/agent-identity and POST /api/agent-session |
src/csp.rs |
Your Content-Security-Policy with the Bunkhouse sources added |
templates/agent-embed-*.html |
The script tags for one installation mode |
public/agent-identity.js, public/agent-auth-headers.js |
Page hooks, loaded before the loader |
The endpoints use these crates; the example’s Cargo.lock pins the exact versions:
[package]name = "bunkhouse-axum-example"version = "0.0.0"edition = "2024"publish = false
[dependencies]axum = "=0.8.9"# Only for HKDF key derivation of the session cookie key (same cookie crate tower-sessions uses).cookie = { version = "0.18", features = ["key-expansion"] }jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }rand = "0.10"reqwest = { version = "0.13.5", default-features = false, features = ["rustls"] }serde = { version = "1", features = ["derive"] }serde_json = "1"tokio = { version = "1", features = ["macros", "rt-multi-thread"] }tower-sessions = { version = "0.15.0", features = ["signed"] }Identity token (recommended)
Section titled “Identity token (recommended)”Set the server environment. Only AGENT_PUBLIC_KEY and the origins ever reach the browser:
AGENT_PUBLIC_KEY=pk_...AGENT_SIGNING_SECRET=ss_... # server onlyAGENT_SIGNING_KID=... # key ID shown with the signing secretAGENT_ALLOWED_ORIGINS='["https://app.example.com"]'# AGENT_API_BASE and AGENT_EMBED_ORIGIN default to https://api.bunkhouse.ai and https://embed.bunkhouse.aimain forces this configuration once at startup, so a wrong value stops the process instead of failing later:
// 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.use std::{env, sync::LazyLock};
use reqwest::Url;
pub struct AgentConfig { pub public_key: String, pub allowed_origins: Vec<String>, pub api_base: String, pub embed_origin: String,}
fn exact_origin(name: &str, value: String) -> String { let exact = Url::parse(&value).is_ok_and(|url| url.origin().ascii_serialization() == value); assert!(exact, "{name} must be an exact origin like https://app.example.com"); value}
pub static AGENT: LazyLock<AgentConfig> = LazyLock::new(|| { let public_key = env::var("AGENT_PUBLIC_KEY").unwrap_or_default(); assert!(public_key.starts_with("pk_"), "AGENT_PUBLIC_KEY must be the site's public key (pk_...)"); let origins: Vec<String> = serde_json::from_str(&env::var("AGENT_ALLOWED_ORIGINS").unwrap_or_default()).unwrap_or_default(); assert!(!origins.is_empty(), r#"AGENT_ALLOWED_ORIGINS must be a JSON array such as ["https://app.example.com"]"#); let var_or = |name: &str, default: &str| env::var(name).unwrap_or_else(|_| default.to_owned()); AgentConfig { public_key, allowed_origins: origins.into_iter().map(|o| exact_origin("AGENT_ALLOWED_ORIGINS", o)).collect(), api_base: exact_origin("AGENT_API_BASE", var_or("AGENT_API_BASE", "https://api.bunkhouse.ai")), embed_origin: exact_origin("AGENT_EMBED_ORIGIN", var_or("AGENT_EMBED_ORIGIN", "https://embed.bunkhouse.ai")), }});The router signs a ten-minute HS256 token with jsonwebtoken for the user your session already authenticated: header {"alg":"HS256","typ":"JWT","kid":...}, the full ss_... secret as the key. It rejects other methods (405 with Allow), requests whose Sec-Fetch-Site is not same-origin (403) and signed-out users (401, never a login redirect), and a map_response layer marks every response Cache-Control: no-store. The subject comes only from the server session, never from the query, body or headers. The same file contains the server mint endpoint used by the alternative below.
// Bunkhouse endpoints: GET /api/agent-identity (identity token mode) and POST /api/agent-session// (server mint mode). Both vouch only for the user your existing session already authenticated.use std::{ env, sync::LazyLock, time::{Duration, SystemTime, UNIX_EPOCH},};
use axum::{ Json, Router, http::{HeaderMap, HeaderValue, StatusCode, header}, middleware::map_response, response::{IntoResponse, Response}, routing::{get, post},};use jsonwebtoken::{Algorithm, EncodingKey, Header};use serde::Serialize;use serde_json::json;
use crate::config::AGENT;
/// The signed-in user of a request. Implement axum's `OptionalFromRequestParts` for it on top of your/// existing authentication: `Some` with the user's stable ID, `None` when signed out.pub struct AgentUser { pub id: String,}
#[derive(Serialize)]struct Claims<'a> { sub: &'a str, aud: &'a str, iat: u64, exp: u64,}
/// HS256 identity token: kid header, sub = stable opaque user ID, aud = public key, 10 minutes.pub fn sign_identity_token(user_id: &str, secret: &str, kid: &str) -> jsonwebtoken::errors::Result<String> { let iat = SystemTime::now().duration_since(UNIX_EPOCH).expect("clock before 1970").as_secs(); let header = Header { kid: Some(kid.to_owned()), ..Header::new(Algorithm::HS256) }; let claims = Claims { sub: user_id, aud: &AGENT.public_key, iat, exp: iat + 600 }; jsonwebtoken::encode(&header, &claims, &EncodingKey::from_secret(secret.as_bytes()))}
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| reqwest::Client::builder().timeout(Duration::from_secs(15)).build().expect("HTTP client"));
fn error(status: StatusCode, code: &str) -> Response { (status, Json(json!({ "error": code }))).into_response()}
fn method_not_allowed(allow: &'static str) -> Response { let mut res = error(StatusCode::METHOD_NOT_ALLOWED, "method_not_allowed"); res.headers_mut().insert(header::ALLOW, HeaderValue::from_static(allow)); res}
/// A credential from the server environment, or 503 naming only the missing variable.fn credential(name: &str) -> Result<String, Response> { env::var(name).ok().filter(|v| !v.is_empty()).ok_or_else(|| { eprintln!("Bunkhouse: {name} is not set"); error(StatusCode::SERVICE_UNAVAILABLE, "not_configured") })}
fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { headers.get(name).and_then(|v| v.to_str().ok())}
async fn identity(headers: HeaderMap, user: Option<AgentUser>) -> Result<Response, Response> { if header_str(&headers, "sec-fetch-site") != Some("same-origin") { return Err(error(StatusCode::FORBIDDEN, "forbidden")); } let user = user.ok_or_else(|| error(StatusCode::UNAUTHORIZED, "unauthorized"))?; let secret = credential("AGENT_SIGNING_SECRET")?; let kid = credential("AGENT_SIGNING_KID")?; let token = sign_identity_token(&user.id, &secret, &kid) .map_err(|_| error(StatusCode::INTERNAL_SERVER_ERROR, "signing_failed"))?; Ok(Json(json!({ "identityToken": token })).into_response())}
async fn mint(headers: HeaderMap, user: Option<AgentUser>) -> Result<Response, Response> { // The embed's POST carries no CSRF token; these three checks are this route's CSRF defense. let origin = header_str(&headers, "origin").filter(|o| AGENT.allowed_origins.iter().any(|a| a == o)); let Some(origin) = origin.filter(|_| { header_str(&headers, "x-agent-embed") == Some("1") && header_str(&headers, "sec-fetch-site") == Some("same-origin") }) else { return Err(error(StatusCode::FORBIDDEN, "forbidden")); }; let user = user.ok_or_else(|| error(StatusCode::UNAUTHORIZED, "unauthorized"))?; let key = credential("AGENT_EMBED_SECRET_KEY")?; let body = json!({ "publicKey": AGENT.public_key, "mode": "signed", "userId": user.id, "origin": origin }); let upstream = async { let res = CLIENT .post(format!("{}/v1/embed/sessions", AGENT.api_base)) .bearer_auth(key) .header(header::CONTENT_TYPE, "application/json") .body(body.to_string()) .send() .await?; let content_type = res.headers().get(header::CONTENT_TYPE).cloned(); Ok::<_, reqwest::Error>((res.status(), content_type, res.bytes().await?)) }; let (status, content_type, bytes) = upstream.await.map_err(|_| error(StatusCode::BAD_GATEWAY, "session_service_unavailable"))?; let content_type = content_type.unwrap_or(HeaderValue::from_static("application/json")); Ok((status, [(header::CONTENT_TYPE, content_type)], bytes).into_response())}
/// The two routes; every response, errors and 405s included, is `Cache-Control: no-store`.pub fn router() -> Router { Router::new() .route("/api/agent-identity", get(identity).fallback(|| async { method_not_allowed("GET") })) .route("/api/agent-session", post(mint).fallback(|| async { method_not_allowed("POST") })) .layer(map_response(|mut res: Response| async { res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); res }))}The handlers take Option<AgentUser>, an ordinary Axum extractor. Implementing OptionalFromRequestParts for it is the seam to your authentication: return the signed-in user’s stable ID, or None. The example reads it from the tower-sessions session:
/// The signed-in user of this request (your existing authentication), or None.impl<S: Send + Sync> OptionalFromRequestParts<S> for AgentUser { type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Option<Self>, Response> { let session = Session::from_request_parts(parts, state).await.map_err(IntoResponse::into_response)?; Ok(current_user(&session).await?.map(|user| AgentUser { id: user.id })) }}Merge agent::router() into your app before the session layer, so the layer wraps the Bunkhouse routes too:
let app = Router::new() // Bunkhouse endpoints; the session layer below wraps them, so they see the signed-in user. .merge(agent::router()) .route("/", get(home)) .route("/second", get(second)) .route("/login", post(login)) .route("/logout", post(logout)) .route("/api/startup-items", get(startup_items)) .route("/api/notes", post(create_note)) .route("/api/notes/last", get(last_note)) .route("/agent-hints.json", get(hints)) .route("/agent-identity.js", get(|| async { js(include_str!("../public/agent-identity.js")) })) .route("/agent-auth-headers.js", get(|| async { js(include_str!("../public/agent-auth-headers.js")) })) .route("/app.js", get(|| async { js(include_str!("../public/app.js")) })) .route("/healthz", get(healthz)) .layer(sessions) .layer(middleware::from_fn(csp::content_security_policy));In the page head, load the page hooks (the identity hook, then the getAuthHeaders hook described below) and then the loader, before your application’s own scripts. window.AgentEmbedConfig is then complete when the loader runs. The hooks are external same-origin scripts, so no inline script needs a CSP exception:
<!-- Identity token mode (recommended): the page hooks first, then the loader, before the app's scripts. --><script src="/agent-identity.js" nonce="{{nonce}}"></script><script src="/agent-auth-headers.js" nonce="{{nonce}}"></script><script src="{{embed_origin}}/embed.js" nonce="{{nonce}}" data-public-key="{{public_key}}" data-hints-url="/agent-hints.json"></script>// 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; },};The example has no template crate: templates/index.html is compiled in with include_str!, every {{slot}} is filled with an HTML-escaped value, and the chosen agent-embed-* fragment replaces the <!-- agent-embed --> marker. Only public values go into these slots: the public key, embed origin, nonce, the session’s own CSRF token and page text. Never put ss_ or sk_ values or the session secret into a template.
<meta name="csrf-token" content="{{csrf_token}}" /><!-- agent-embed --><!-- The application's own JavaScript comes after the loader. --><script src="/app.js" nonce="{{nonce}}" defer></script>The example picks one of the three agent-embed-* fragments with a test-only ?mode= switch. Your application uses exactly one.
Server mint alternative
Section titled “Server mint alternative”Instead of the identity hook, add data-session-endpoint to the loader; the getAuthHeaders hook still loads before it. Do not keep agent-identity.js on the page: when getIdentityToken is defined it takes precedence.
<!-- Server mint mode: no identity hook; the embed posts to the same-origin session endpoint. --><script src="/agent-auth-headers.js" nonce="{{nonce}}"></script><script src="{{embed_origin}}/embed.js" nonce="{{nonce}}" data-public-key="{{public_key}}" data-hints-url="/agent-hints.json" data-session-endpoint="/api/agent-session"></script>POST /api/agent-session in src/agent.rs 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 reqwest 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.
Axum has no built-in CSRF protection. If your app adds a CSRF layer, exempt only /api/agent-session: the embed’s POST carries no CSRF token, and the three header checks are that route’s protection.
Anonymous alternative
Section titled “Anonymous alternative”Anonymous sessions need no identity hook or session endpoint: the loader, preceded here by the getAuthHeaders hook. 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.
<!-- Anonymous mode (enable it for the site first): the auth-headers hook, then the loader. --><script src="/agent-auth-headers.js" nonce="{{nonce}}"></script><script src="{{embed_origin}}/embed.js" nonce="{{nonce}}" data-public-key="{{public_key}}" data-hints-url="/agent-hints.json"></script>Sign-in, sign-out and navigation
Section titled “Sign-in, sign-out and navigation”The example signs in and out with ordinary form posts 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. The session layer uses a signed, HttpOnly, SameSite cookie whose key is derived from a server secret, and login calls session.cycle_id() so each sign-in gets a new session ID:
let sessions = SessionManagerLayer::new(MemoryStore::default()) .with_name("example.sid") .with_signed(Key::derive_from(secret.as_bytes())) .with_same_site(SameSite::Lax) // HttpOnly by default; Secure everywhere except the loopback HTTP test origin. .with_secure(APP_ORIGIN.starts_with("https://"));If your app 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 page shell, not in per-page fragments.
Auth headers, user IDs and CSP
Section titled “Auth headers, user IDs and CSP”The assistant calls your API with the page’s cookies; supply any extra header your API requires with getAuthHeaders. The example’s session owns a random CSRF token, rendered into a <meta> tag (empty when signed out) and checked on every write:
// The app's own CSRF token (rendered by the server for the signed-in session) 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-token"]')?.content; return token ? { "X-CSRF-Token": token } : {}; },};Each mode’s fragment loads this hook before the loader. It merges into window.AgentEmbedConfig, so it keeps the identity hook defined before it. Its values never reach the model or the sandbox.
Send a stable, opaque user ID (the example’s fixture-alice), not a name or email address.
Add the Bunkhouse sources to your existing policy rather than replacing it. The example’s from_fn middleware sets a fresh nonce per response, hands it to handlers as a request extension and puts it on the script tags; no 'unsafe-inline' or 'unsafe-eval' is needed:
// 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 (the CspNonce request// extension) goes on the page's script tags.use axum::{ extract::Request, http::{HeaderValue, header}, middleware::Next, response::Response,};
use crate::config::AGENT;
#[derive(Clone)]pub struct CspNonce(pub String);
pub async fn content_security_policy(mut req: Request, next: Next) -> Response { let nonce: String = rand::random::<[u8; 16]>().iter().map(|b| format!("{b:02x}")).collect(); req.extensions_mut().insert(CspNonce(nonce.clone())); let mut res = next.run(req).await; let policy = [ "default-src 'self'".to_owned(), format!("script-src 'self' 'nonce-{nonce}' {}", AGENT.embed_origin), format!("style-src 'self' 'nonce-{nonce}'"), format!("frame-src {}", AGENT.embed_origin), format!("connect-src 'self' {}", AGENT.api_base), "img-src 'self' data:".to_owned(), "object-src 'none'".to_owned(), "base-uri 'self'".to_owned(), "form-action 'self'".to_owned(), "frame-ancestors 'self'".to_owned(), ] .join("; "); res.headers_mut().insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_str(&policy).expect("valid CSP header")); res}Verify
Section titled “Verify”From a checkout of the Bunkhouse repository, with Docker and local Supabase running:
node tests/framework-integrations/run.ts axumThe run builds the example image (cargo build --locked --release), 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 |
agent::router() is merged after .layer(sessions) (so the session layer does not wrap it), or your OptionalFromRequestParts implementation does not find the user. |
| 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 CSRF layer must exempt only /api/agent-session. |
| 503 | The signing secret, key ID or mint key for the mode you use is not set. |
| 502 from the mint endpoint | The server cannot reach AGENT_API_BASE. A minimal runtime image needs a CA bundle at /etc/ssl/certs/ca-certificates.crt; the example’s Dockerfile copies it from the build stage. |
| The pane reports a CSP block | Add the embed origin to script-src and frame-src and the API to connect-src. |
| Startup requests are not discovered | The loader must come before your application’s scripts in the head. |
| Two launchers | Render the loader once, in the page shell. |
| A secret in page source | Only AGENT_PUBLIC_KEY and origins belong in templates; keep ss_ and sk_ values in server environment variables. |
Related
Section titled “Related”- Installation, Security and the Page API
- Axum: API documentation, extractors, middleware, tower-sessions