AuthGeek Demo
AuthGeek · Architecture Deep-Dive

Keeping Tokens Off the Browser:
The BFF Session Pattern

The gateway pattern demo puts a raw JWT in the browser's localStorage and says so plainly. This post covers the alternative: a small, purpose-built session broker that keeps the JWT server-side entirely, using this project's own code — not a third-party proxy binary marketed by name.

session-serviceAPISIXRedisKeycloakOIDC ROPCBFF PatternZero Trust

The problem with browsers holding JWTs

The standard SPA auth pattern looks like this: user logs in, the browser receives a JWT access token, stores it in localStorage or memory, and sends it as Authorization: Bearer <token> on every request. It works. But it has a property that's easy to overlook until it isn't: the token contains your user's identity and permissions, and it's sitting in a JavaScript runtime that is one XSS vulnerability away from exfiltration.

Once an attacker has your access token, they can impersonate you until it expires. The JWT expiry window becomes a window during which the breach is silent — there's no server-side switch to flip.

The attack surface
  • XSS reads JWT from localStorage → attacker gains full user access
  • JWT in browser memory → visible in DevTools, accessible to browser extensions
  • Refresh token in browser → attacker can silently maintain access after password change
  • No server-side revocation → can't invalidate a stolen token before expiry

The BFF pattern: move the tokens server-side

The Backend-for-Frontend (BFF) pattern solves this by keeping tokens on the server. The browser holds an opaque session identifier — a random value with no claims, no expiry, no user data. The server maps that identifier to the actual JWT and refresh token stored in Redis.

Auth flow — once at login
1. Browser POSTs credentials to session-service
2. session-service authenticates with Keycloak (ROPC internally)
3. JWT + refresh token stored in Redis with TTL
4. Browser receives an opaque session_id — no JWT content, ever
API request flow — every call
1. Browser sends X-Session-ID header
2. GET /internal/resolve → session-service looks up the session_id in Redis
3. Auto-refreshes the access token if it's within 30 seconds of expiry
4. Returns resolved user_id, user_tier — never the JWT itself

Why this is our own code, not a proxy binary

There's a well-known off-the-shelf option for this — a reverse proxy that sits in front of your app, handles the OIDC dance, and stores sessions in Redis. It's a fine choice in production. We're deliberately not naming or featuring one here, for the same reason the rest of this site avoids naming specific vendor products as the point of a demo: the pattern is what matters, not the brand.

Building the ~150-line FastAPI session-service ourselves also made the mechanics visible in a way a black-box proxy wouldn't: the opaque session ID generation, the Redis key structure, the auto-refresh logic, the revocation path.

Building it surfaced the architecture questions early: What happens when the resolve call can't reach Redis? (Fail-closed — no session found means no access.) What's the revocation model? (DELETE the Redis key → instant, no JWT expiry window.) How does auto-refresh work transparently? (Check exp on every resolve, call Keycloak's refresh endpoint if within 30s.)

What session-service actually does

What it does
  • Brokers login via Keycloak ROPC (Direct Grant)
  • Stores JWT + refresh token in Redis (server-side only)
  • Hands the browser an opaque session_id — no claims, no expiry
  • Resolves session_id → user context on every API call
  • Auto-refreshes the access token before expiry (transparent)
  • Handles logout + Keycloak refresh token revocation
What it doesn't do
  • Expose any JWT or token to the browser
  • Require changes to backend services that read the resolved headers
  • Replace OPA — it's authentication brokering, not authorization
  • Replace APISIX — it's a session broker, not a gateway
  • Store passwords
  • Handle business-rule enforcement

Instant revocation: the biggest win

With pure JWT authentication — the gateway pattern demo, for example — revoking access requires waiting for the token to expire. A 2-minute access token means a 2-minute window after logout during which the user can still access protected resources, because logout there is purely client-side (clear localStorage; the JWT itself doesn't know it's been "logged out").

With the BFF pattern and Redis, revocation is instant:

1. Browser calls DELETE /session-api/logout
2. session-service revokes the Keycloak refresh token and deletes the Redis key
3. Next request: session_id is present but the Redis key is gone → 401
4. The JWT's own expiry is irrelevant — nobody has it anymore

The same model works for admin-initiated revocation: delete the Redis key server-side and the user is locked out immediately. For a terminated employee or a compromised account, the revocation window is measured in milliseconds, not minutes.

Full architecture

Container topology
Internet
    │
    ▼
[Caddy :80/443]
    │
    ├── /auth/*         → [Keycloak :8080] → Postgres
    │
    ├── /session-api/*  → [session-service :8000] → [Redis :6379]
    │                          (JWT + refresh token, per-session, TTL'd)
    │
    ├── /gateway-demo/*  ┐
    │  /demo-api/*       ├── [APISIX :9080]  ← real JWKS verification
    │  /todo-api/*       │        │
    └── /sidecar-demo/*  ┘        ├── [OPA :8181]              ← gateway pattern
                                  ├── [Demo API :3000] ← [OPA sidecar]  ← sidecar pattern
                                  └── [Todo API :3001] ← [OPA sidecar]  ← sidecar pattern

    └── /*              → [Demo UI]  ← Astro static site

Tradeoffs and when to use this pattern

Use this when
  • Your SPA needs to call protected APIs
  • You need instant session revocation
  • XSS is a realistic threat surface
  • You have compliance requirements around token storage
  • Multiple backend services share the same user context
Consider alternatives when
  • Server-to-server API calls (use client credentials, no browser involved)
  • Mobile apps (use PKCE with the platform secure store, no proxy needed)
  • Simple single-service apps without sensitive data
  • You can't add a session broker in front of your existing API
  • The added hop latency matters at your scale — see the gateway pattern demo instead

Enterprise IDP federation

In the demo, Keycloak is both the IdP and the SP. In a real enterprise deployment, your company's existing IDP becomes the upstream. Keycloak federates to it via OIDC or SAML, enriches the token with additional claims (user_tier, department, region), and issues the JWT that session-service stores.

Nothing downstream changes. session-service doesn't care which IdP issued the JWT — it only talks to Keycloak. OPA evaluates the claims. Your applications get the same resolved user context regardless of whether the user authenticated with a local Keycloak account, their corporate SSO, or a federated external IdP.

Compare it live

See the tradeoff directly: the gateway pattern demo keeps the JWT in the browser; the sidecar demo moves authorization off the gateway entirely. This post's session-service pattern is the third option — keep the token off the browser altogether.