CodeIgniter
This guide adds Bunkhouse to a server-rendered CodeIgniter 4 application that signs users in with Shield. 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 | CodeIgniter 4.7.5, CodeIgniter Shield 1.4.1 (session authenticator), PHP views |
| Runtime | PHP 8.4 (8.4.27 in the tested image), CI_ENVIRONMENT=production, served through public/index.php by PHP’s built-in web server |
| Example | apps/docs/integrations/codeigniter/ 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 Shield’s session authenticator with two demo users in SQLite; 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 |
|---|---|
app/Config/Agent.php |
Validates the public Bunkhouse configuration |
app/Controllers/Agent.php |
GET /api/agent-identity and POST /api/agent-session |
app/Config/Routes.php, app/Config/Filters.php |
The two routes, and the mint route’s CSRF exemption |
app/Config/ContentSecurityPolicy.php |
Your Content-Security-Policy with the Bunkhouse sources added |
app/Views/agent-embed-*.php |
The script tags for one installation mode |
public/agent-identity.js, public/agent-auth-headers.js |
Page hooks, loaded before the loader |
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.aiA config class reads and validates the public values whenever config(Agent::class) loads them, so a misconfigured page fails instead of rendering a broken loader:
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;use RuntimeException;
/** * Bunkhouse public configuration, validated whenever it loads. Signing and mint credentials are read * only by the endpoints that use them, so each mode needs only its own secret. */class Agent extends BaseConfig{ public string $publicKey = '';
/** @var list<string> */ public array $allowedOrigins = [];
public string $apiBase = 'https://api.bunkhouse.ai'; public string $embedOrigin = 'https://embed.bunkhouse.ai';
public function __construct() { parent::__construct();
$this->publicKey = (string) env('AGENT_PUBLIC_KEY', ''); if (! str_starts_with($this->publicKey, 'pk_')) { throw new RuntimeException("AGENT_PUBLIC_KEY must be the site's public key (pk_...)"); }
$origins = json_decode((string) env('AGENT_ALLOWED_ORIGINS', '[]'), true); if (! is_array($origins) || ! array_is_list($origins) || $origins === []) { throw new RuntimeException('AGENT_ALLOWED_ORIGINS must be a JSON array such as ["https://app.example.com"]'); } $this->allowedOrigins = array_map(static fn ($o) => self::exactOrigin('AGENT_ALLOWED_ORIGINS', (string) $o), $origins); $this->apiBase = self::exactOrigin('AGENT_API_BASE', (string) env('AGENT_API_BASE', $this->apiBase)); $this->embedOrigin = self::exactOrigin('AGENT_EMBED_ORIGIN', (string) env('AGENT_EMBED_ORIGIN', $this->embedOrigin)); }
private static function exactOrigin(string $name, string $value): string { $url = parse_url($value); $origin = isset($url['scheme'], $url['host']) && in_array($url['scheme'], ['http', 'https'], true) ? $url['scheme'] . '://' . $url['host'] . (isset($url['port']) ? ':' . $url['port'] : '') : null; if ($origin !== $value) { throw new RuntimeException("{$name} must be an exact origin like https://app.example.com"); }
return $value; }}The controller signs a ten-minute HS256 token with hash_hmac for the user your session already authenticated. It rejects requests whose Sec-Fetch-Site is not same-origin (403) and signed-out users (401, never a redirect to your login page), and marks every response Cache-Control: no-store through $this->response->noCache(). 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.
<?php
namespace App\Controllers;
use CodeIgniter\HTTP\ResponseInterface;use Config\Agent as AgentConfig;use Throwable;
/** * 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. */class Agent extends BaseController{ /** * Your existing authentication: the signed-in user's stable opaque ID, or null. * Shield's session authenticator; `public_id` is the example's opaque subject column. */ private function currentUserId(): ?string { $user = auth('session')->user();
return $user?->public_id === null ? null : (string) $user->public_id; }
public function identity(): ResponseInterface { $this->response->noCache(); if ($this->request->getHeaderLine('Sec-Fetch-Site') !== 'same-origin') { return $this->error(403, 'forbidden'); } $userId = $this->currentUserId(); if ($userId === null) { return $this->error(401, 'unauthorized'); } $secret = (string) env('AGENT_SIGNING_SECRET', ''); $kid = (string) env('AGENT_SIGNING_KID', ''); if ($secret === '' || $kid === '') { return $this->notConfigured($secret === '' ? 'AGENT_SIGNING_SECRET' : 'AGENT_SIGNING_KID'); }
return $this->response->setJSON(['identityToken' => self::signIdentityToken($userId, $secret, $kid)]); }
public function session(): ResponseInterface { $this->response->noCache(); $agent = config(AgentConfig::class); // The embed's POST carries no CSRF token (the route is exempt in Config\Filters); these three // checks are this route's CSRF defense. $origin = $this->request->getHeaderLine('Origin'); if ( ! in_array($origin, $agent->allowedOrigins, true) || $this->request->getHeaderLine('X-Agent-Embed') !== '1' || $this->request->getHeaderLine('Sec-Fetch-Site') !== 'same-origin' ) { return $this->error(403, 'forbidden'); } $userId = $this->currentUserId(); if ($userId === null) { return $this->error(401, 'unauthorized'); } $key = (string) env('AGENT_EMBED_SECRET_KEY', ''); if ($key === '') { return $this->notConfigured('AGENT_EMBED_SECRET_KEY'); }
try { $upstream = service('curlrequest', ['http_errors' => false, 'timeout' => 15], null, null, false)->post( $agent->apiBase . '/v1/embed/sessions', [ 'headers' => ['Authorization' => "Bearer {$key}", 'Content-Type' => 'application/json'], 'body' => json_encode(['publicKey' => $agent->publicKey, 'mode' => 'signed', 'userId' => $userId, 'origin' => $origin], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), ], ); } catch (Throwable) { return $this->error(502, 'session_service_unavailable'); }
return $this->response ->setStatusCode($upstream->getStatusCode()) ->setHeader('Content-Type', $upstream->getHeaderLine('Content-Type') ?: 'application/json') ->setBody($upstream->getBody()); }
/** Every other method on either route. */ public function methodNotAllowed(string $allow): ResponseInterface { $this->response->noCache()->setHeader('Allow', $allow);
return $this->error(405, 'method_not_allowed'); }
/** HS256 identity token: kid header, sub = stable opaque user ID, aud = public key, 10 minutes. */ public static function signIdentityToken(string $userId, string $secret, string $kid): string { $iat = time(); $header = self::base64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT', 'kid' => $kid], JSON_THROW_ON_ERROR)); $payload = self::base64url(json_encode(['sub' => $userId, 'aud' => config(AgentConfig::class)->publicKey, 'iat' => $iat, 'exp' => $iat + 600], JSON_THROW_ON_ERROR)); $signature = self::base64url(hash_hmac('sha256', "{$header}.{$payload}", $secret, true));
return "{$header}.{$payload}.{$signature}"; }
private static function base64url(string $bytes): string { return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); }
private function error(int $status, string $error): ResponseInterface { return $this->response->setStatusCode($status)->setJSON(['error' => $error]); }
private function notConfigured(string $variable): ResponseInterface { log_message('error', 'Bunkhouse: {variable} is not set', ['variable' => $variable]);
return $this->error(503, 'not_configured'); }}currentUserId() is the seam to your authentication: return the signed-in user’s stable ID, or null. With Shield it is auth('session')->user(). The example stores an opaque public_id per user (fixture-alice, fixture-bob) in its own column, so Bunkhouse never sees the username, email address or sequential primary key; a column holding a UUID generated at registration works the same way.
Register the routes. CodeIgniter answers an unmatched method with 404, so a catch-all add() route returns 405 with an Allow header; the verb routes match first:
// Bunkhouse endpoints. The verb routes match first; add() answers every other method with 405.$routes->get('api/agent-identity', 'Agent::identity');$routes->add('api/agent-identity', 'Agent::methodNotAllowed/GET');$routes->post('api/agent-session', 'Agent::session');$routes->add('api/agent-session', 'Agent::methodNotAllowed/POST');In the page head, include the identity hook, then the auth-headers hook (see Auth headers), then the loader, all before your application’s own scripts. Both hooks are external same-origin scripts that define window.AgentEmbedConfig before the loader reads it; csp_script_nonce() puts the response’s nonce on each tag:
<?php // Identity token mode (recommended): both page hooks first, then the loader, before the app's scripts. ?><script src="/agent-identity.js" <?= csp_script_nonce() ?>></script><script src="/agent-auth-headers.js" <?= csp_script_nonce() ?>></script><script src="<?= esc($agent->embedOrigin) ?>/embed.js" <?= csp_script_nonce() ?> data-public-key="<?= esc($agent->publicKey) ?>" 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; },};<?= $user !== null ? csrf_meta() : "" ?><?php // Page hooks, then the loader (one installation mode). ?><?= $this->include('agent-embed-' . $mode) ?><!-- The application's own JavaScript comes after the loader. --><script src="/app.js" <?= csp_script_nonce() ?> defer></script>The example layout includes one of the three agent-embed-* views, selected by 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. Do not keep agent-identity.js on the page: when getIdentityToken is defined it takes precedence.
<?php // Server mint mode: no identity hook; the embed posts to the same-origin session endpoint. ?><script src="/agent-auth-headers.js" <?= csp_script_nonce() ?>></script><script src="<?= esc($agent->embedOrigin) ?>/embed.js" <?= csp_script_nonce() ?> data-public-key="<?= esc($agent->publicKey) ?>" data-hints-url="/agent-hints.json" data-session-endpoint="/api/agent-session"></script>POST /api/agent-session in app/Controllers/Agent.php 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 CodeIgniter’s CURLRequest 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.
Keep CodeIgniter’s CSRF filter on globally and exempt only the Bunkhouse mint route. The embed’s mint POST carries no CSRF token, and the three header checks are that route’s protection. The identity route keeps the filter, so a wrong-method POST may get CodeIgniter’s CSRF 403 instead of the 405, which is fine:
'csrf' => ['except' => [ // The embed's mint POST carries no CSRF token; the endpoint checks Origin, // X-Agent-Embed and Sec-Fetch-Site instead. 'api/agent-session',]],Anonymous alternative
Section titled “Anonymous alternative”Anonymous sessions need no identity hook or session endpoint, 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.
<?php // Anonymous mode (enable it for the site first): the auth-headers hook, then the loader. ?><script src="/agent-auth-headers.js" <?= csp_script_nonce() ?>></script><script src="<?= esc($agent->embedOrigin) ?>/embed.js" <?= csp_script_nonce() ?> data-public-key="<?= esc($agent->publicKey) ?>" 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 CSRF-protected form posts to Shield’s session authenticator, then redirects back to the same page. Signing in while already signed in logs the previous user out first. Shield regenerates the session ID on login and logout, and the CSRF token on login. 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:
public function login(): ResponseInterface{ if (auth()->loggedIn()) { auth()->logout(); // account switch } $result = auth()->attempt([ 'username' => (string) $this->request->getPost('username'), 'password' => (string) $this->request->getPost('password'), ]); if (! $result->isOK()) { return $this->response->setStatusCode(401)->setBody('Invalid username or password'); }
return $this->response->redirect(self::safeNext($this->request->getPost('next')), 'auto', 303);}
public function logout(): ResponseInterface{ auth()->logout();
return $this->response->redirect(self::safeNext($this->request->getPost('next')), 'auto', 303);}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 views.
The session cookie is HttpOnly and SameSite=Lax (app/Config/Cookie.php); the example turns off Secure only for its loopback HTTP test origin.
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. CodeIgniter’s CSRF filter accepts the token in the X-CSRF-TOKEN header (Config\Security::$headerName). The example uses session-based CSRF tokens with $regenerate = false, so the token in the page stays valid for the session, and renders it with csrf_meta() for signed-in users:
// The session's CodeIgniter CSRF token (csrf_meta() renders it for signed-in users) 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="X-CSRF-TOKEN"]')?.content; return token ? { "X-CSRF-TOKEN": token } : {}; },};Each agent-embed-* view 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 public_id), not a name or email address.
Turn on CodeIgniter’s Content Security Policy (Config\App::$CSPEnabled = true) and add the Bunkhouse sources to your existing policy rather than replacing it. CodeIgniter generates a fresh nonce per response and adds it to script-src when a view calls csp_script_nonce(); no 'unsafe-inline' or 'unsafe-eval' is needed. The example’s policy is CodeIgniter’s default with object-src 'none' and frame-ancestors 'self', plus:
public function __construct(){ parent::__construct(); // Bunkhouse: the loader and pane from the embed origin, session requests to the API. $agent = config(Agent::class); $this->scriptSrc = [...(array) $this->scriptSrc, $agent->embedOrigin]; $this->scriptSrcElem = [...(array) $this->scriptSrcElem, $agent->embedOrigin]; $this->frameSrc = [...(array) $this->frameSrc, $agent->embedOrigin]; $this->connectSrc = [...(array) $this->connectSrc, $agent->apiBase];}Verify
Section titled “Verify”From a checkout of the Bunkhouse repository, with Docker and local Supabase running:
node tests/framework-integrations/run.ts codeigniterThe 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 |
currentUserId() does not find the user: check auth('session')->user() and that your user has an opaque ID. |
| A redirect to your login page instead of 401 | A Shield session filter covers api/*; leave the Bunkhouse routes out of it, the controller checks the session itself. |
| 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. The CSRF filter must exempt api/agent-session. |
| 404 instead of 405 | The catch-all add() routes are missing. |
| 503 | The signing secret, key ID or mint key for the mode you use is not set (the log names the variable). |
| The pane reports a CSP block | Add the embed origin to script-src, script-src-elem and frame-src and the API to connect-src, and call csp_script_nonce() on each script tag. |
| 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 views; keep ss_ and sk_ values in server environment variables. |
Related
Section titled “Related”- Installation, Security and the Page API
- CodeIgniter: Routing, Filters, Content Security Policy, Shield