Actix Web
This guide adds Bunkhouse to a server-rendered Actix Web 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 | Actix Web 4.18.1, actix-session 0.11 (cookie store), awc 3.11 (rustls), jsonwebtoken 11.1 |
| Runtime | Stable Rust (rustc 1.99.1 in the tested image), release build on Debian bookworm-slim |
| Example | apps/docs/integrations/actix-web/ 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
actix-sessionwith 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 Bunkhouse code uses these crates. awc makes the server mint request; it needs one rustls crypto provider:
[package]name = "bunkhouse-actix-web-example"version = "0.1.0"edition = "2024"publish = false
[dependencies]actix-web = { version = "=4.18.1", default-features = false, features = ["macros", "cookies", "secure-cookies"] }actix-session = { version = "=0.11.0", features = ["cookie-session"] }awc = { version = "=3.11.0", default-features = false, features = ["rustls-0_23-webpki-roots"] }jsonwebtoken = { version = "=11.1.0", default-features = false, features = ["rust_crypto"] }rand = "=0.9.5"# awc's TLS (rustls) needs exactly one crypto provider; ring is pure cc, no cmake.rustls = { version = "=0.23.46", default-features = false, features = ["ring"] }serde = { version = "=1.0.229", features = ["derive"] }serde_json = "=1.0.154"url = "=2.5.8"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.ai// 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;
use url::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: &str) -> Result<String, String> { match Url::parse(value) { Ok(url) if url.origin().ascii_serialization() == value => Ok(value.to_owned()), _ => Err(format!("{name} must be an exact origin like https://app.example.com")), }}
impl AgentConfig { pub fn from_env() -> Result<Self, String> { let public_key = env::var("AGENT_PUBLIC_KEY").unwrap_or_default(); if !public_key.starts_with("pk_") { return Err("AGENT_PUBLIC_KEY must be the site's public key (pk_...)".into()); } let origins: Vec<String> = serde_json::from_str(&env::var("AGENT_ALLOWED_ORIGINS").unwrap_or_else(|_| "[]".into())) .map_err(|_| r#"AGENT_ALLOWED_ORIGINS must be a JSON array such as ["https://app.example.com"]"#)?; if origins.is_empty() { return Err(r#"AGENT_ALLOWED_ORIGINS must be a JSON array such as ["https://app.example.com"]"#.into()); } Ok(Self { public_key, allowed_origins: origins.iter().map(|o| exact_origin("AGENT_ALLOWED_ORIGINS", o)).collect::<Result<_, _>>()?, api_base: exact_origin("AGENT_API_BASE", &env::var("AGENT_API_BASE").unwrap_or_else(|_| "https://api.bunkhouse.ai".into()))?, embed_origin: exact_origin("AGENT_EMBED_ORIGIN", &env::var("AGENT_EMBED_ORIGIN").unwrap_or_else(|_| "https://embed.bunkhouse.ai".into()))?, }) }}The handlers sign a ten-minute HS256 token with jsonwebtoken for the user your session already authenticated. They reject 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 mark 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;use std::time::{Duration, SystemTime, UNIX_EPOCH};
use actix_web::http::{StatusCode, header};use actix_web::{HttpRequest, HttpResponse, HttpResponseBuilder, web};use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};use serde::Serialize;use serde_json::json;
use crate::config::AgentConfig;
/// Your existing authentication: the signed-in user's stable, opaque ID. Its `FromRequest`/// implementation is the seam to your sessions; the endpoints receive `None` when signed out.pub struct CurrentUser { 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, public_key: &str, secret: &str, kid: &str) -> jsonwebtoken::errors::Result<String> { let iat = SystemTime::now().duration_since(UNIX_EPOCH).expect("system clock before 1970").as_secs(); let mut header = Header::new(Algorithm::HS256); header.kid = Some(kid.to_owned()); let claims = Claims { sub: user_id, aud: public_key, iat, exp: iat + 600 }; encode(&header, &claims, &EncodingKey::from_secret(secret.as_bytes()))}
pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( web::resource("/api/agent-identity") .route(web::get().to(identity)) .default_service(web::to(|| method_not_allowed("GET"))), ) .service( web::resource("/api/agent-session") .route(web::post().to(session)) .default_service(web::to(|| method_not_allowed("POST"))), );}
fn no_store(status: StatusCode) -> HttpResponseBuilder { let mut res = HttpResponse::build(status); res.insert_header((header::CACHE_CONTROL, "no-store")); res}
fn error(status: StatusCode, code: &str) -> HttpResponse { no_store(status).json(json!({ "error": code }))}
async fn method_not_allowed(allow: &'static str) -> HttpResponse { no_store(StatusCode::METHOD_NOT_ALLOWED) .insert_header((header::ALLOW, allow)) .json(json!({ "error": "method_not_allowed" }))}
/// A server-only credential, read where it is used. Missing: 503, logging only the variable name.fn credential(name: &str) -> Result<String, HttpResponse> { 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_value<'a>(req: &'a HttpRequest, name: &str) -> Option<&'a str> { req.headers().get(name)?.to_str().ok()}
async fn identity(req: HttpRequest, user: Option<CurrentUser>, agent: web::Data<AgentConfig>) -> HttpResponse { if header_value(&req, "sec-fetch-site") != Some("same-origin") { return error(StatusCode::FORBIDDEN, "forbidden"); } let Some(user) = user else { return error(StatusCode::UNAUTHORIZED, "unauthorized") }; let (secret, kid) = match (credential("AGENT_SIGNING_SECRET"), credential("AGENT_SIGNING_KID")) { (Ok(secret), Ok(kid)) => (secret, kid), (Err(res), _) | (_, Err(res)) => return res, }; match sign_identity_token(&user.id, &agent.public_key, &secret, &kid) { Ok(token) => no_store(StatusCode::OK).json(json!({ "identityToken": token })), Err(_) => error(StatusCode::INTERNAL_SERVER_ERROR, "signing_failed"), }}
async fn session(req: HttpRequest, user: Option<CurrentUser>, agent: web::Data<AgentConfig>, client: web::Data<awc::Client>) -> HttpResponse { // The embed's POST carries no CSRF token; these three checks are this route's CSRF defense. let origin = header_value(&req, "origin").filter(|o| agent.allowed_origins.iter().any(|a| a == o)); let (Some(origin), Some("1"), Some("same-origin")) = (origin, header_value(&req, "x-agent-embed"), header_value(&req, "sec-fetch-site")) else { return error(StatusCode::FORBIDDEN, "forbidden"); }; let Some(user) = user else { return error(StatusCode::UNAUTHORIZED, "unauthorized") }; let key = match credential("AGENT_EMBED_SECRET_KEY") { Ok(key) => key, Err(res) => return res, }; let upstream = client .post(format!("{}/v1/embed/sessions", agent.api_base)) .insert_header((header::AUTHORIZATION, format!("Bearer {key}"))) .timeout(Duration::from_secs(15)) .send_json(&json!({ "publicKey": agent.public_key, "mode": "signed", "userId": user.id, "origin": origin })) .await; let Ok(mut upstream) = upstream else { return error(StatusCode::BAD_GATEWAY, "session_service_unavailable") }; let Ok(body) = upstream.body().await else { return error(StatusCode::BAD_GATEWAY, "session_service_unavailable") }; let content_type = upstream.headers().get(header::CONTENT_TYPE).cloned().unwrap_or(header::HeaderValue::from_static("application/json")); no_store(upstream.status()).insert_header((header::CONTENT_TYPE, content_type)).body(body)}The endpoints take Option<CurrentUser>. Its FromRequest implementation is the seam to your authentication: return the signed-in user’s stable ID, or fail extraction when signed out. Register the routes inside your session middleware with configure:
/// The signed-in user of this request (your existing authentication). The Bunkhouse endpoints take/// `Option<CurrentUser>`, so a failed extraction means "signed out".impl FromRequest for CurrentUser { type Error = actix_web::Error; type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { let user = req.get_session().get::<DemoUser>("user").ok().flatten(); ready(user.map(|u| CurrentUser { id: u.id }).ok_or_else(|| actix_web::error::ErrorUnauthorized("signed out"))) }}// Bunkhouse endpoints, inside the session middleware so they see the signed-in user..configure(agent::configure)In the page head, window.AgentEmbedConfig must be defined before the loader: load the identity hook, then the auth-headers hook (see Auth headers), then the loader, all before your application’s own scripts. The hooks are external same-origin scripts, so no inline script needs a CSP exception. The example compiles its templates in with include_str! and fills only HTML-escaped slots ({{nonce}}, {{embed_origin}}, {{public_key}}); a template engine renders the same tags:
<!-- 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; },};{{csrf_meta}}<!-- One agent-embed-*.html fragment: the page hooks, then the loader. -->{{agent_embed}}<!-- The application's own JavaScript comes after the loader. --><script src="/app.js" nonce="{{nonce}}" defer></script>The example page inserts one of the three agent-embed-* fragments at {{agent_embed}}, selected by a test-only ?mode= switch; each fragment holds that mode’s page hooks followed by the loader. 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 auth-headers hook still loads first. 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 awc 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.
Actix Web has no built-in CSRF middleware. If you use one, 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 only the loader; the example keeps its auth-headers hook in front of it. 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 cookie is private (encrypted and authenticated), HttpOnly and SameSite, and every sign-in renews the session:
.wrap( SessionMiddleware::builder(CookieSessionStore::default(), key.clone()) .cookie_name("example.sid".into()) .cookie_content_security(CookieContentSecurity::Private) .cookie_http_only(true) .cookie_same_site(SameSite::Lax) .cookie_secure(secure) .build(),)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 layout, not in per-page templates.
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 for signed-in users 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 } : {}; },};The hook merges into window.AgentEmbedConfig alongside the identity hook, and every mode’s fragment loads it before the loader. 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, stores it as a request extension for the page handler 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 actix_web::body::MessageBody;use actix_web::dev::{ServiceRequest, ServiceResponse};use actix_web::http::header::{CONTENT_SECURITY_POLICY, HeaderValue};use actix_web::middleware::Next;use actix_web::{Error, HttpMessage, web};
use crate::config::AgentConfig;
#[derive(Clone)]pub struct CspNonce(pub String);
pub async fn content_security_policy(req: ServiceRequest, next: Next<impl MessageBody>) -> Result<ServiceResponse<impl MessageBody>, Error> { let nonce: String = rand::random::<[u8; 16]>().iter().map(|b| format!("{b:02x}")).collect(); let agent = req.app_data::<web::Data<AgentConfig>>().expect("AgentConfig app data").clone(); req.extensions_mut().insert(CspNonce(nonce.clone())); let mut res = next.call(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(CONTENT_SECURITY_POLICY, HeaderValue::from_str(&policy)?); Ok(res)}Verify
Section titled “Verify”From a checkout of the Bunkhouse repository, with Docker and local Supabase running:
node tests/framework-integrations/run.ts actix-webThe 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 routes are registered outside your session middleware, or the CurrentUser extractor 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 middleware must exempt only /api/agent-session. |
| 503 | The signing secret, key ID or mint key for the mode you use is not set. |
| Mint returns 502 | The server cannot reach AGENT_API_BASE; awc needs a rustls crypto provider and root certificates (the example uses webpki roots). |
| 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 layout. |
| 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
- Actix Web: URL dispatch, Middleware, actix-session