CookbookTokens & OAuth2
BFF whoami pattern
A simple backend-for-frontend pattern for SPA auth
For SPAs that want to be served from a custom domain but auth via Olympus, a Backend-for-Frontend (BFF) is the safest pattern. The SPA never sees tokens; the BFF (a thin server-side proxy) handles all the auth state.
Architecture
Browser (SPA) ──cookie──► BFF (Caddy + small Node app) ──tokens──► API services
│
└──tokens──► OlympusRoles
- SPA: serves the UI. Calls
/api/...on its own origin. - BFF: runs on
/api/...of same origin. Manages tokens. Proxies to backend APIs. - Olympus: issues tokens. Validates them on each request (via BFF).
Endpoints
/api/whoami
import { Hono } from "hono";
const app = new Hono();
app.get("/api/whoami", async (c) => {
const session = c.req.header("cookie")
? await getKratosSession(c.req.header("cookie"))
: null;
if (!session) return c.json({ authenticated: false }, 401);
return c.json({
authenticated: true,
user: {
id: session.identity.id,
email: session.identity.traits.email,
name: session.identity.traits.first_name,
},
});
});SPA calls on mount:
const { authenticated, user } = await fetch("/api/whoami").then(r => r.json());
if (!authenticated) window.location.href = "/login";/api/login (initiate)
app.get("/api/login", async (c) => {
// Generate state for CSRF
const state = crypto.randomBytes(16).toString("hex");
await c.cookies.set("auth_state", state, { httpOnly: true, secure: true, sameSite: "lax" });
// Redirect to Olympus OAuth2
const params = new URLSearchParams({
response_type: "code",
client_id: process.env.OAUTH2_CLIENT_ID,
redirect_uri: `${process.env.BASE_URL}/api/callback`,
scope: "openid offline_access profile email",
state,
code_challenge: pkce.codeChallenge,
code_challenge_method: "S256",
});
await c.cookies.set("pkce_verifier", pkce.codeVerifier, { httpOnly: true });
return c.redirect(`${process.env.HYDRA_URL}/oauth2/auth?${params}`);
});/api/callback
app.get("/api/callback", async (c) => {
const { code, state } = c.req.query();
const stateCookie = await c.cookies.get("auth_state");
if (state !== stateCookie) return c.text("Invalid state", 400);
const verifier = await c.cookies.get("pkce_verifier");
const tokens = await fetch(`${HYDRA_URL}/oauth2/token`, {
method: "POST",
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: `${process.env.BASE_URL}/api/callback`,
client_id: process.env.OAUTH2_CLIENT_ID,
code_verifier: verifier,
}),
}).then(r => r.json());
// Store tokens in BFF-managed session
const session = await sessions.create({
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
id_token: tokens.id_token,
expires_at: Date.now() + tokens.expires_in * 1000,
});
await c.cookies.set("session_id", session.id, {
httpOnly: true, secure: true, sameSite: "lax", maxAge: 86400 * 30
});
return c.redirect("/");
});Tokens never reach the browser. Session cookie is HttpOnly.
/api/proxy/:path*
Proxy to upstream APIs with the access token:
app.all("/api/proxy/*", async (c) => {
const session = await getSession(c.req.header("cookie"));
if (!session) return c.text("Unauthorized", 401);
// Auto-refresh if expired
if (Date.now() > session.expires_at) {
const refreshed = await refreshTokens(session.refresh_token);
await sessions.update(session.id, refreshed);
session.access_token = refreshed.access_token;
}
const target = c.req.path.replace("/api/proxy/", "");
const upstream = `${UPSTREAM_API}/${target}`;
return fetch(upstream, {
headers: {
...c.req.headers,
"authorization": `Bearer ${session.access_token}`,
},
method: c.req.method,
body: c.req.body,
});
});/api/logout
app.post("/api/logout", async (c) => {
const session = await getSession(c.req.header("cookie"));
if (session) {
await sessions.destroy(session.id);
}
await c.cookies.delete("session_id");
return c.redirect("/");
});Storage
BFF needs a session store. Options:
- In-memory (single instance, lost on restart): fine for dev.
- Redis (multi-instance, persistent): production.
- Encrypted cookie (no server state, but cookie size limit ~4KB): if you can fit tokens.
For Olympus deployment alongside, use Redis.
Why not store tokens in the SPA?
- XSS = token theft. With BFF, XSS only steals session cookie (HttpOnly).
- Refresh tokens in SPA = long-lived secret in untrusted environment.
- BFF can introspect / validate every request server-side.
Why not just use Kratos sessions?
You could! If your SPA and BFF are same-origin to Kratos's domain, the Kratos session cookie works directly. But:
- Cross-origin (SPA on
app.your-domainbut Olympus onciam.your-domain): cookie boundary. - You want OAuth2 scopes (Kratos session doesn't have scopes; OAuth2 access tokens do).
- You want to issue tokens to multiple services.
If neither applies, skip BFF and use Kratos session directly.
Library: simple BFF
There are libraries:
- oauth4webapi, OAuth2 helper.
- hono-oidc-bff (community).
Or roll your own, ~300 lines of Hono.