Skip to content

Symfony

This guide adds Bunkhouse to a server-rendered Symfony application that signs users in with the Security bundle’s session firewall. 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 Symfony 8.1 (symfony/framework-bundle 8.1.10, security-bundle 8.1.10, twig-bundle 8.1.9, http-client 8.1.10), Twig 3.30, attribute routing, prod environment
Runtime PHP 8.4 (8.4.27 in the tested image)
Example apps/docs/integrations/symfony/ in the Bunkhouse repository

The test image serves the app with PHP’s built-in server; in production, run the same code behind PHP-FPM or FrankenPHP as usual.

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 a Security firewall with sessions. The example uses form_login with two in-memory demo users; your own user provider and authenticators take their place. Bunkhouse only needs one thing from them: the signed-in user’s stable ID on the server.
File Purpose
src/AgentConfig.php Validates the public Bunkhouse configuration
src/Controller/AgentController.php GET /api/agent-identity and POST /api/agent-session
src/EventSubscriber/ContentSecurityPolicySubscriber.php Your Content-Security-Policy with the Bunkhouse sources added
templates/agent_embed/*.html.twig The script tags for one installation mode
public/agent-identity.js, public/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

AgentConfig reads the public values with #[Autowire(env: ...)] and rejects a malformed key or origin when the container creates it. The defaults for the two Bunkhouse origins are env() parameters, and the service is lazy so cache:warmup at build time needs no runtime environment:

src/AgentConfig.php
<?php
// Bunkhouse public configuration, validated when the service container creates it. The CSP listener
// needs it on every request, so a misconfigured app fails every request, health checks included.
// Signing and mint credentials are read only by the endpoints that use them.
namespace App;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class AgentConfig
{
/** @var list<string> */
public array $allowedOrigins;
public string $apiBase;
public string $embedOrigin;
public function __construct(
#[Autowire(env: 'AGENT_PUBLIC_KEY')] public string $publicKey,
#[Autowire(env: 'json:AGENT_ALLOWED_ORIGINS')] mixed $allowedOrigins,
#[Autowire(env: 'AGENT_API_BASE')] string $apiBase,
#[Autowire(env: 'AGENT_EMBED_ORIGIN')] string $embedOrigin,
) {
if (!str_starts_with($publicKey, 'pk_')) {
throw new \InvalidArgumentException("AGENT_PUBLIC_KEY must be the site's public key (pk_...)");
}
if (!\is_array($allowedOrigins) || !array_is_list($allowedOrigins) || [] === $allowedOrigins) {
throw new \InvalidArgumentException('AGENT_ALLOWED_ORIGINS must be a JSON array such as ["https://app.example.com"]');
}
$this->allowedOrigins = array_map(static fn (mixed $o) => self::exactOrigin('AGENT_ALLOWED_ORIGINS', $o), $allowedOrigins);
$this->apiBase = self::exactOrigin('AGENT_API_BASE', $apiBase);
$this->embedOrigin = self::exactOrigin('AGENT_EMBED_ORIGIN', $embedOrigin);
}
private static function exactOrigin(string $name, mixed $value): string
{
$url = \is_string($value) ? parse_url($value) : false;
$origin = \is_array($url) && isset($url['scheme'], $url['host']) && \in_array($url['scheme'], ['http', 'https'], true)
? $url['scheme'].'://'.$url['host'].(isset($url['port']) ? ':'.$url['port'] : '')
: null;
if (null === $origin || $origin !== $value) {
throw new \InvalidArgumentException("$name must be an exact origin like https://app.example.com");
}
return $origin;
}
}
config/services.yaml
parameters:
# Defaults for the optional Bunkhouse origins.
env(AGENT_API_BASE): 'https://api.bunkhouse.ai'
env(AGENT_EMBED_ORIGIN): 'https://embed.bunkhouse.ai'
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude: '../src/Kernel.php'
# Lazy so that building the cache needs no runtime environment; validated on first use.
App\AgentConfig:
lazy: true

The controller signs a ten-minute HS256 token with PHP’s hash_hmac for the user your firewall already authenticated. It rejects requests whose Sec-Fetch-Site is not same-origin (403) and signed-out users (401, never a login redirect), and marks every response Cache-Control: no-store. Other HTTP methods fall through to a lower-priority route on the same path that answers 405 with Allow, so even that response is uncacheable JSON. The subject comes only from the server session, never from the query, body or headers. The same controller contains the server mint endpoint used by the alternative below.

src/Controller/AgentController.php
<?php
// Bunkhouse endpoints: GET /api/agent-identity (identity token mode) and POST /api/agent-session
// (server mint mode). Both vouch only for the user your security firewall already authenticated.
namespace App\Controller;
use App\AgentConfig;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class AgentController extends AbstractController
{
public function __construct(
private readonly AgentConfig $agent,
private readonly HttpClientInterface $http,
private readonly LoggerInterface $logger,
// Server-only credentials; null when unset, so each mode needs only its own secret.
#[Autowire(env: 'default::AGENT_SIGNING_SECRET')] private readonly ?string $signingSecret,
#[Autowire(env: 'default::AGENT_SIGNING_KID')] private readonly ?string $signingKid,
#[Autowire(env: 'default::AGENT_EMBED_SECRET_KEY')] private readonly ?string $embedSecretKey,
) {
}
#[Route('/api/agent-identity', name: 'agent_identity', methods: ['GET'])]
public function identity(Request $request): JsonResponse
{
if ('same-origin' !== $request->headers->get('Sec-Fetch-Site')) {
return self::noStore(['error' => 'forbidden'], 403);
}
$userId = $this->currentUserId();
if (null === $userId) {
return self::noStore(['error' => 'unauthorized'], 401);
}
if (!$this->signingSecret) {
return $this->notConfigured('AGENT_SIGNING_SECRET');
}
if (!$this->signingKid) {
return $this->notConfigured('AGENT_SIGNING_KID');
}
return self::noStore(['identityToken' => $this->signIdentityToken($userId, $this->signingSecret, $this->signingKid)]);
}
#[Route('/api/agent-session', name: 'agent_session', methods: ['POST'])]
public function session(Request $request): Response
{
// The embed's POST carries no CSRF token; these three checks are this route's CSRF defense.
$origin = $request->headers->get('Origin');
if (!\in_array($origin, $this->agent->allowedOrigins, true)
|| '1' !== $request->headers->get('X-Agent-Embed')
|| 'same-origin' !== $request->headers->get('Sec-Fetch-Site')) {
return self::noStore(['error' => 'forbidden'], 403);
}
$userId = $this->currentUserId();
if (null === $userId) {
return self::noStore(['error' => 'unauthorized'], 401);
}
if (!$this->embedSecretKey) {
return $this->notConfigured('AGENT_EMBED_SECRET_KEY');
}
try {
$upstream = $this->http->request('POST', $this->agent->apiBase.'/v1/embed/sessions', [
'auth_bearer' => $this->embedSecretKey,
'json' => ['publicKey' => $this->agent->publicKey, 'mode' => 'signed', 'userId' => $userId, 'origin' => $origin],
'max_duration' => 15,
]);
$body = $upstream->getContent(false);
$type = $upstream->getHeaders(false)['content-type'][0] ?? 'application/json';
return new Response($body, $upstream->getStatusCode(), ['Content-Type' => $type, 'Cache-Control' => 'no-store']);
} catch (TransportExceptionInterface) {
return self::noStore(['error' => 'session_service_unavailable'], 502);
}
}
// Other methods on the two paths: 405 with Allow, also uncacheable.
#[Route('/api/agent-identity', name: 'agent_identity_405', defaults: ['allow' => 'GET'], priority: -1)]
#[Route('/api/agent-session', name: 'agent_session_405', defaults: ['allow' => 'POST'], priority: -1)]
public function methodNotAllowed(string $allow): JsonResponse
{
return self::noStore(['error' => 'method_not_allowed'], 405, ['Allow' => $allow]);
}
/** Your existing authentication: the signed-in user's stable, opaque ID, or null. */
private function currentUserId(): ?string
{
$user = $this->getUser();
// The demo's in-memory users are identified by name, so map them to opaque IDs. With your own
// User class, return its stable ID here (for example (string) $user->getId()), never a name or email.
return null === $user ? null : 'fixture-'.$user->getUserIdentifier();
}
/** HS256 identity token: kid header, sub = stable opaque user ID, aud = public key, 10 minutes. */
private function signIdentityToken(string $userId, string $secret, string $kid): string
{
$base64url = static fn (string $bytes): string => rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
$iat = time();
$header = $base64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT', 'kid' => $kid], \JSON_THROW_ON_ERROR));
$payload = $base64url(json_encode(['sub' => $userId, 'aud' => $this->agent->publicKey, 'iat' => $iat, 'exp' => $iat + 600], \JSON_THROW_ON_ERROR));
return "$header.$payload.".$base64url(hash_hmac('sha256', "$header.$payload", $secret, true));
}
private function notConfigured(string $variable): JsonResponse
{
$this->logger->error("Bunkhouse: $variable is not set");
return self::noStore(['error' => 'not_configured'], 503);
}
/** @param array<string, string> $headers */
private static function noStore(array $data, int $status = 200, array $headers = []): JsonResponse
{
return new JsonResponse($data, $status, ['Cache-Control' => 'no-store'] + $headers);
}
}

currentUserId() is the seam to your authentication. It uses $this->getUser(), so the two routes must sit behind the same session firewall (and security context) as your sign-in, not a stateless API firewall. The demo’s in-memory users are identified by name, so the example maps alice to the opaque ID fixture-alice; with your own User class, return its stable ID instead.

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, so window.AgentEmbedConfig is complete when the loader runs. The hooks are external same-origin scripts, so no inline script needs a CSP exception. A Twig global exposes the public configuration to the partial:

templates/agent_embed/identity.html.twig
{# 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="{{ agent.embedOrigin }}/embed.js" nonce="{{ nonce }}"
data-public-key="{{ agent.publicKey }}" data-hints-url="/agent-hints.json"></script>
public/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;
},
};
config/packages/twig.yaml
twig:
globals:
# Public Bunkhouse configuration for the agent_embed/*.html.twig partials.
agent: '@App\AgentConfig'
templates/base.html.twig (excerpt)
{% if app.user %}<meta name="csrf-token" content="{{ csrf_token('notes') }}">{% endif %}
{{ include('agent_embed/' ~ mode ~ '.html.twig') }}
{# The application's own JavaScript comes after the loader. #}
<script src="/app.js" nonce="{{ nonce }}" defer></script>

The example layout includes one of the three agent_embed/* partials, 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.

templates/agent_embed/mint.html.twig
{# 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="{{ agent.embedOrigin }}/embed.js" nonce="{{ nonce }}"
data-public-key="{{ agent.publicKey }}" data-hints-url="/agent-hints.json"
data-session-endpoint="{{ path('agent_session') }}"></script>

POST /api/agent-session in AgentController 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 through Symfony’s HttpClient with the mint key 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.

Symfony has no global CSRF middleware: tokens are checked where you ask for them (forms, form_login, logout, isCsrfTokenValid(), #[IsCsrfTokenValid]). Do not add such a check to /api/agent-session: the embed’s POST carries no CSRF token, and the three header checks are that route’s protection.

Anonymous sessions need no identity hook or session endpoint: only the loader, plus the getAuthHeaders hook if your API needs extra headers. 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.

templates/agent_embed/anonymous.html.twig
{# Anonymous mode (enable it for the site first): no identity hook, only the auth-headers hook and the loader. #}
<script src="/agent-auth-headers.js" nonce="{{ nonce }}"></script>
<script src="{{ agent.embedOrigin }}/embed.js" nonce="{{ nonce }}"
data-public-key="{{ agent.publicKey }}" data-hints-url="/agent-hints.json"></script>

The example signs in and out with ordinary form posts handled by the firewall: form_login and logout, both with Symfony’s CSRF tokens. Sign-in returns to the page’s _target_path; a small LogoutEvent subscriber does the same for sign-out. 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. Symfony migrates the session ID on login and clears its CSRF tokens:

config/packages/security.yaml (excerpt)
firewalls:
main:
lazy: true
provider: demo_users
# Form posts with Symfony's CSRF token; success returns to the page's _target_path.
form_login:
login_path: app_home
check_path: app_login
enable_csrf: true
logout:
path: app_logout
enable_csrf: true
templates/base.html.twig (excerpt)
<form id="login-form" method="post" action="{{ path('app_login') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<input type="hidden" name="_target_path" value="{{ app.request.requestUri }}">
<label>Username <input name="_username" autocomplete="username"></label>
<label>Password <input name="_password" type="password" autocomplete="current-password"></label>
<button type="submit">Sign in</button>
</form>
{% if app.user %}
<form method="post" action="{{ path('app_logout') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
<input type="hidden" name="_target_path" value="{{ app.request.requestUri }}">
<button type="submit">Sign out</button>
</form>
{% endif %}

The session cookie is HttpOnly and SameSite=Lax, and Secure on HTTPS requests (behind a TLS-terminating proxy, configure framework.trusted_proxies so Symfony sees HTTPS):

config/packages/framework.yaml
framework:
# The example derives the kernel secret from its demo session secret.
secret: '%env(DEMO_SESSION_SECRET)%'
csrf_protection: true
session:
name: example_session
cookie_httponly: true
cookie_samesite: lax
# Secure on HTTPS requests; the loopback HTTP test origin gets a non-Secure cookie.
cookie_secure: auto

If your app changes the signed-in user without a full page load (a fetch login, Turbo or 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 base layout, not in per-page templates or blocks that page fragments replace.

The assistant calls your API with the page’s cookies; supply any extra header your API requires with getAuthHeaders. The example renders a Symfony CSRF token (csrf_token('notes')) into a <meta> tag for signed-in users, the hook sends it as X-CSRF-Token, and the notes route checks it:

public/agent-auth-headers.js
// 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 } : {};
},
};
src/Controller/AppController.php (excerpt)
// Symfony's CSRF token for the session (rendered into <meta name="csrf-token">), sent as X-CSRF-Token.
if (!$this->isCsrfTokenValid('notes', (string) $request->headers->get('X-CSRF-Token'))) {
return $this->json(['error' => 'csrf'], 403);
}

Every mode’s partial loads this hook before the loader. It merges into window.AgentEmbedConfig, so it works with the identity hook in either order. 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 sets a fresh nonce per request as a request attribute, which the templates put on their script tags, and adds the header in a kernel.response subscriber; no 'unsafe-inline' or 'unsafe-eval' is needed:

src/EventSubscriber/ContentSecurityPolicySubscriber.php
<?php
// 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 request (the csp_nonce request
// attribute) goes on the page's script tags.
namespace App\EventSubscriber;
use App\AgentConfig;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class ContentSecurityPolicySubscriber implements EventSubscriberInterface
{
public function __construct(private readonly AgentConfig $agent)
{
}
public static function getSubscribedEvents(): array
{
return [KernelEvents::REQUEST => ['onRequest', 512], KernelEvents::RESPONSE => 'onResponse'];
}
public function onRequest(RequestEvent $event): void
{
if ($event->isMainRequest()) {
$event->getRequest()->attributes->set('csp_nonce', base64_encode(random_bytes(16)));
}
}
public function onResponse(ResponseEvent $event): void
{
$nonce = $event->getRequest()->attributes->get('csp_nonce');
if (!$event->isMainRequest() || !\is_string($nonce)) {
return;
}
$event->getResponse()->headers->set('Content-Security-Policy', implode('; ', [
"default-src 'self'",
"script-src 'self' 'nonce-$nonce' {$this->agent->embedOrigin}",
"style-src 'self' 'nonce-$nonce'",
"frame-src {$this->agent->embedOrigin}",
"connect-src 'self' {$this->agent->apiBase}",
"img-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
]));
}
}

If you use a bundle such as NelmioSecurityBundle for CSP, add the same three sources to its script-src, frame-src and connect-src lists and use its nonce on the tags instead.

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

Terminal window
node tests/framework-integrations/run.ts symfony

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. The test uses plain HTTP on loopback, so it does not cover Secure cookies or TLS. 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 matched by a different firewall (for example a stateless API firewall) than the one your users sign in with, or currentUserId() 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. Do not put a CSRF token check on /api/agent-session.
503 The signing secret, key ID or mint key for the mode you use is not set.
Environment variable not found A required AGENT_* variable is missing at runtime; AgentConfig must stay lazy if you warm the cache without them.
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 base layout.
A secret in page source Only AGENT_PUBLIC_KEY and origins belong in templates; keep ss_ and sk_ values in server environment variables.