A complete, runnable reference for the Backend-for-Frontend (BFF) session pattern: browser-app OAuth 2.1 and OpenID Connect Core 1.0 with no tokens in browser JS or storage, and a live test that fails if one ever leaks.
No access, refresh, or ID token ever reaches browser JavaScript or browser storage, and an end-to-end test asserts the browser-visible surfaces. The browser holds only an opaque
HttpOnlysession cookie; the tokens live server-side in a Redis-compatible store. The only deliberate ID-token front-channel use is the server-generated RP-initiated logout redirect, where the token is sent asid_token_hintto the IdP.
- The browser stores no tokens and runs no OIDC library: just an opaque
__Host-sidcookie and a CSRF token. - A confidential server-side BFF owns the OAuth client role, split into a dedicated Auth Service (the OIDC client) and an API Gateway (routing + bearer injection).
- Every
/api/**call is the phantom-token pattern: the gateway swaps the opaque cookie for a real access token (resolved by the Auth Service) and injects it as theBearerproxied to the Resource Server.
flowchart LR
B["Browser SPA<br/>opaque __Host-sid cookie only"] -->|"GET /api/…"| G[API Gateway]
G -->|"resolve sid"| A["Auth Service (BFF)<br/>holds the tokens"]
A --> V[(Session Store)]
G -->|"Bearer access_token"| R[Resource Server]
A -.->|"OAuth round-trip"| K[IdP]
G -.->|"Client Credentials service token"| K
If you read nothing else, run just up and then just e2e-auth: login → API call → token refresh → logout, end to end.
Most OIDC walkthroughs hand the SPA a public client running PKCE in the browser. That puts the access token where any XSS payload can read it. This reference demonstrates the secure default instead and proves it holds:
| Decision | This reference | Common alternative |
|---|---|---|
| Where tokens live | Server-side BFF; access and refresh tokens never reach the browser, and the ID token only as a server-emitted id_token_hint on the RP-logout redirect (never in JS/storage/cookies) |
A public-client SPA running PKCE in the browser (tokens are XSS-reachable), or a backend that still hands the access token to JavaScript |
| Component shape | Split Auth Service (the OAuth/OIDC client) + API Gateway (routing, bearer injection) | One combined service; valid, but mixes the OAuth-client and API-gateway roles |
| Session state | Two server-side keyspaces, tx:{state} (pre-auth, keyed by the OAuth state) and sess:{sid} (post-auth); no pre-auth session cookie, so no session-fixation class |
A framework HTTP-session blob |
| Provider coupling | Branch on iss / aud / scopes / claim paths from .well-known/openid-configuration; differences live in config |
Provider-specific APIs baked into Java or the gateway |
It implements RFC 9700 (OAuth 2.0 Security BCP, also the OAuth 2.1 baseline) and OIDC Core §3.1.3.7 for ID-token validation, across two flows: browser login (Authorization Code + PKCE with saved-request replay) and service-to-service (Client Credentials).
Full rationale and reconsideration triggers live in docs/architecture/architecture-decisions.md.
- A live test asserts no token reaches browser-visible surfaces. The
id_tokennever reaches browser JS, storage, SPA-readable JSON, or SPA-visible cookies; only the server's/auth/logout/continue→ IdP redirect carriesid_token_hint, and the test confirms that browser-observable path. - Each control is linked to its spec, code, and test. The Security controls table maps each control to its RFC/OIDC section, the code that implements it, and the gate that proves it.
- Identity Providers are swappable through standard OIDC configuration. The code avoids provider-specific branches; issuer, audiences, scopes, claim paths, and client identities are config. The one pinned crypto choice is the JWS signature algorithm — RS256, hardcoded in both services — so an IdP that signs with a different algorithm (e.g. ES256/PS256) needs a one-line code change, not just config. Provider-specific setup notes live in the provider-adapter docs.
just e2e-portabilityruns the same code against a second realm whose tokens carry a different shape.
| Component | Role |
|---|---|
frontend/ |
React + TypeScript SPA. Cookie-authenticated. No OIDC client library in the browser. |
auth-service/ |
Confidential OIDC client (Nimbus oauth2-oidc-sdk). Owns /auth/*, the OAuth round-trip, session storage, and /internal/resolve. |
api-gateway/ |
APISIX standalone + custom Lua plugin (bff-session). Owns the /api/** allowlist, sid resolution via /internal/resolve (holds no session-store handle), bearer injection, and signed-CSRF validation. |
backend-resource-server/ |
JWT validation only; never sees session cookies. |
authorization-server/ |
Keycloak realm + Compose service. |
The vendor choices (Keycloak, APISIX, Valkey) are interchangeable. Appendix A of SPEC-0001 is the vendor-swap matrix.
Flow 1: Login (Authorization Code + PKCE)
Login starts when the browser hits a protected /api/** URL with no session, or when the user clicks "Sign in". On the no-session /api/** case:
- top-level navigation →
302to/auth/login?return_to=…; - XHR →
401, and the SPA navigates itself.
The Auth Service then runs the OAuth round-trip and returns the browser to the originally requested URL with the session and CSRF cookies set.
sequenceDiagram
autonumber
actor U as User
participant B as Browser (SPA)
participant G as API Gateway
participant A as Auth Service (BFF)
participant V as Session Store
participant K as IdP
Note over B: Browser holds only an opaque __Host-sid cookie + a CSRF token —<br/>never an access, refresh, or ID token.
U->>B: Open a protected URL
B->>G: GET /api/… (no session cookie)
G-->>B: 302 → /auth/login (navigation) · 401 (XHR)
B->>G: GET /auth/login?return_to=…
G->>A: Forward /auth/login
A->>A: Generate state, nonce, PKCE, browser-binding
A->>V: Store tx:{state} (verifier, nonce, saved request, binding hash)
A-->>G: 302 → IdP authorization endpoint (response_type=code, PKCE S256)
G-->>B: Forward redirect + transaction cookie
B->>K: Authenticate
K-->>B: 302 → /auth/callback/idp?code&state (+ optional iss)
B->>G: GET /auth/callback/idp (+ transaction cookie)
G->>A: Forward callback
A->>V: Atomically consume tx:{state}
A->>K: Exchange code (+ PKCE verifier, client secret)
K-->>A: access + refresh + ID tokens
Note over A,K: Tokens exist only server-side, from here on.
A->>A: Validate id_token
A->>V: Create sess:{sid} + logout indexes
A-->>G: 302 → original URL + __Host-sid + CSRF cookie
G-->>B: Forward redirect + cookies
Flow 2: Identity check (/auth/me)
The SPA holds no session state of its own. It calls /auth/me to learn whether a session exists and who the user is. /auth/me is a pure read; it never extends the session and never returns a token.
sequenceDiagram
autonumber
participant B as Browser (SPA)
participant G as API Gateway
participant A as Auth Service (BFF)
participant V as Session Store
Note over B: On mount, the SPA checks who is signed in — its only window into session state.
B->>G: GET /auth/me (sends the __Host-sid cookie)
G->>A: Forward /auth/me
A->>V: Read the session record (pure read, no idle-window slide)
alt session valid
A-->>G: 200 allowlisted identity claims
G-->>B: 200 identity claims (+ optional auth_time, acr)
Note over B: Authenticated — render identity and roles (display only, never a token)
else no, expired, or server-deleted session
A-->>G: 401 (Cache-Control: no-store)
G-->>B: 401
Note over B: Anonymous — render the Sign-in prompt
end
Flow 3: Authenticated request (phantom token + transparent refresh)
Every /api/** call carries only the opaque session cookie, the phantom-token pattern, where only the Auth Service touches the session store (see docs/architecture/phantom-token-session-resolution.md):
- The gateway resolves the sid via
/internal/resolve(Client Credentials over an internal RPC). - The Auth Service slides the idle window and refreshes the access token if near expiry.
- The gateway injects the returned token as a bearer for the Resource Server.
sequenceDiagram
autonumber
participant B as Browser (SPA)
participant G as API Gateway
participant A as Auth Service (BFF)
participant V as Session Store
participant K as IdP
participant R as Resource Server
B->>G: GET /api/… (Cookie __Host-sid)
Note over B,G: State-changing methods also send the signed CSRF header.
Note over G: The gateway holds no store handle — it resolves the sid via the Auth Service.
G->>A: POST /internal/resolve (gateway service token + sid)
A->>V: Look up session
alt access token fresh
A->>V: Slide idle window
else access token near expiry
A->>A: Acquire per-session lock
A->>V: Re-read session under lock
alt another caller already refreshed
A->>V: Slide idle window
else still near expiry
A->>K: Refresh-token grant
K-->>A: rotated access + refresh tokens
A->>V: Atomic move sess:{sid}→sess:{sid'} + rotated:{sid} breadcrumb
A->>V: CAS/repoint logout and subject indexes to sid'
end
end
A-->>G: 200 access_token (+ rotated_sid, rotated_csrf when the sid rotated)
opt resolve rotated the sid
Note over B,G: Gateway re-issues __Host-sid and XSRF-TOKEN (bound to sid') on this response.
end
G->>R: GET /api/… + Authorization: Bearer access_token
Note over G,R: Gateway strips the inbound cookie and injects the bearer.<br/>The browser never sends or sees a token.
R->>R: Validate JWT (iss, sig, aud, exp, scope/roles)
R-->>G: 200
G-->>B: 200
Flow 4: Logout (RP-initiated, id_token_hint never reaches SPA code or storage)
The IdP end-session URL carries id_token_hint (PII), so it never reaches SPA JavaScript. The Auth Service hands back a same-origin, single-use handle and emits the IdP redirect itself from /auth/logout/continue.
sequenceDiagram
autonumber
participant B as Browser (SPA)
participant G as API Gateway
participant A as Auth Service (BFF)
participant V as Session Store
participant K as IdP
B->>G: POST /auth/logout (Cookie: __Host-sid, header: CSRF)
G->>A: Forward /auth/logout
A->>A: Validate signed CSRF
A->>V: Delete session + indexes · store single-use logout handle
A-->>G: 200 logoutUrl=/auth/logout/continue?lc=… + evict cookies
G-->>B: Forward same-origin handle + cookie eviction
Note over B,A: The SPA receives only a same-origin handle —<br/>never the IdP URL or id_token_hint.
B->>G: GET /auth/logout/continue?lc=… (top-level navigation)
G->>A: Forward continuation
A->>V: Atomically consume handle → IdP end-session URL
A-->>G: 302 → end_session_endpoint?id_token_hint (Referrer-Policy: no-referrer)
G-->>B: Forward server-emitted redirect
B->>K: GET end_session_endpoint
K-->>B: 302 → /
Flow 5: Service-to-service (Client Credentials)
Machine callers obtain a token directly from the Authorization Server and call the Resource Server with a bearer. Neither the Auth Service nor the API Gateway is in the path.
sequenceDiagram
autonumber
participant SC as Service Client (machine)
participant K as IdP
participant R as Resource Server
Note over SC,R: Machine-to-machine — neither the Browser, Gateway, nor BFF is in the path.
SC->>K: Client Credentials grant (confidential-client authentication)
K-->>SC: access_token (aud, scope)
SC->>R: POST /api/jobs + Authorization: Bearer access_token
R->>R: Validate JWT (iss, sig, aud, exp, scope)
R-->>SC: 200
Wire-level detail (exact cookie attributes, TTLs, validation rules, and the /internal/resolve, sess:{sid}, and signed-CSRF contracts) lives in SPEC-0001.
This reference uses three cookie types, each with its own scope and SameSite value:
| Cookie | Readable by JS? | SameSite |
Why |
|---|---|---|---|
__Host-sid |
No (HttpOnly) |
Lax |
The only credential. No session cookie exists before the initial callback. Lax supports the direct callback-to-saved-request navigation and later top-level cross-site returns while signed CSRF protects state-changing requests. |
XSRF-TOKEN |
Yes | Strict |
Carries an HMAC-SHA256-signed value (<value>.<hmac>, bound to the sid). The SPA echoes it as X-XSRF-TOKEN. Strict because, unlike the session cookie, it's never needed on the cross-site callback. |
oauth_tx |
No (HttpOnly) |
Lax |
Browser-binding cookie issued at /auth/login, scoped to Path=/auth/callback/idp. Its HMAC is stored in tx:{state}; the callback rejects a mismatch, defeating an attacker who exfiltrates (code, state) from a different user-agent. |
Two finer points:
- Why signed double-submit. An attacker with a sibling-subdomain
document.cookiewrite could forge a matching unsigned pair. The HMAC (bound to thesid) makes a forged pair fail validation, so unsigned double-submit is rejected outright. - Sid rotation on refresh (control A6). A token refresh rotates the
sid: the Auth Service atomically movessess:{sid}→sess:{sid'}and leaves a short-livedrotated:{sid}breadcrumb so a request in flight on the old sid follows it rather than losing the session./internal/resolvereturnsrotated_sid,rotated_sid_max_age, androtated_csrf, and the gateway re-issues both the__Host-sidand the HMAC-boundXSRF-TOKEN. This bounds a once-observed sid to a single refresh cycle, not the session lifetime (SECURITY S-5). Breadcrumb and logout-race mechanics are in SPEC-0001.
Local-mode note. Over plain HTTP the session cookie name downgrades to
sidandSecureis dropped, because browsers reject the__Host-prefix withoutSecure. This is a local-only concession; see production hardening.
Each control maps to its reference and the code that implements it.
| Control | Reference | Where |
|---|---|---|
| Authorization Code + PKCE S256 | OIDC Core §3.1.2 | auth-service |
state, nonce, ID-token signature/iss/aud/exp |
OIDC Core §3.1.3 | JwtOidcIdTokenValidator |
at_hash when present |
OIDC Core §3.1.3.7 step 7 | JwtOidcIdTokenValidator |
| Access-token signature/iss/aud/exp plus JOSE `typ=JWT | at+JWT` | RFC 7519, RFC 9068 |
iss query-param mix-up defense |
RFC 9207 | AuthController#callback |
Refresh rejected by AS (invalid_grant) → 409 + session invalidation; realm enables rotation + reuse detection |
RFC 9700 §4.14 | AuthorizationCodeTokenRefreshClient + realm |
| Signed double-submit CSRF (HMAC-SHA256, base64url) | — | SignedCsrfSupport, bff-session.lua |
oauth_tx browser-binding cookie |
— | OAuthTxBinding |
RP-initiated logout with id_token_hint |
OIDC RP-Initiated Logout 1.0 | AuthController#logout |
Step-up: auth_time recency and acr assurance gates on a sensitive route |
OIDC Core §3.1.2.1, RFC 9470 | RS ApiController#admin, AuthController#stepUp, realm auth_time + acr mappers |
redirect_uri pinned via app.base-url (defeats Host-header injection) |
— | AuthController#baseUrl |
Session cookie accepted only as __Host-sid on secure requests (cookie-tossing / forced-login defense) |
— | AuthController#sessionId |
| Per-session refresh lock (in-process default, distributed opt-in) | — | RefreshLock, InProcessRefreshLock, DistributedRefreshKeyLock, RefreshLockConfig, bff-session.lua |
Sid rotation on refresh: atomic sess:{sid}→sess:{sid'} move + rotated:{sid} breadcrumb so in-flight requests follow it |
— | InternalResolveController (A6); proven by reference-flow.spec.ts story 17 and e2e-distributed-lock.sh |
Rate-limit on /auth/login + /auth/callback/idp |
— | apisix.yaml.template |
| Sentinel guard refusing default dev secrets (fail-closed at boot/render) | — | SecretSentinelValidator, render-apisix-config.sh, bff-session.lua |
acr scope (local realm). A fresh interactive login maps to acr=1; remembered-SSO maps to acr=0. The gate rejects any acr below app.step-up.required-acr (default 1). Note that acr=1 is a Level-of-Assurance value; it does not prove MFA. Mapping acr to a real MFA level is per-IdP config, not done here. See RFC9470-compliance.md.
Full rationale in docs/architecture/architecture-decisions.md §F.
- Sender-constrained tokens (DPoP / mTLS). RS bearer tokens are not sender-bound, so network isolation of the Resource Server is load-bearing until added (SECURITY G-8). Reconsider when the RS faces untrusted callers.
- Asymmetric client authentication (
private_key_jwt, mTLS to the AS). Shared-secret auth suffices for the baseline. Reconsider for FAPI / PSD2. - JAR, PAR, RAR. Exact redirect-URI + PKCE + state + nonce cover the flow; scopes cover authorization. Reconsider for multiple ASes or per-resource grants.
- OIDC Front-Channel Logout. RP-initiated logout + OIDC Back-Channel Logout (implemented,
POST /backchannel-logout) cover it; the iframe variant is not. - OIDC Session Management. No browser↔AS session to monitor; state surfaces via
/auth/meor the next/api/**returning 401. - Encrypted-at-rest sessions in Valkey. Local Valkey runs without AUTH/TLS/encryption. Add before any non-local deployment.
Heads-up: the stack is recent (Java 25, Spring Boot 4, Spring Security 7). Exact versions are pinned in
frontend/package.json, the servicepom.xmlfiles, andcompose.yaml.
- React 19 + TypeScript, Vite
- Java 25 + Spring Boot 4 (Auth Service, Resource Server)
- Nimbus
oauth2-oidc-sdkfor OIDC discovery, JWKS, ID-token validation, PKCE - Spring Security 7 (JWT decoder, validator composition)
- Apache APISIX 3 standalone + custom Lua plugin (
lua-resty-http,lua-resty-lock) - Keycloak 26 (embedded H2 via
KC_DB=dev-file; no separate database) - Valkey 9 (Redis-compatible state store)
- Docker Compose
Works on macOS, Linux, and Windows.
Prerequisites
- Docker Desktop (macOS/Windows) or any Docker-compatible engine such as Podman.
- Node 20+ for the SPA dev server.
- A POSIX shell for
scripts/*.sh: built in on macOS/Linux; on Windows use WSL2 (recommended) or Git Bash. - Java 25: only needed on the host if you run the Spring modules or their unit tests outside Docker (Docker builds the Java images for you).
justis optional: it's a command runner; each recipe wraps a script (just uprunssh scripts/up.sh). Install viabrew install just,winget install Casey.Just, orscoop install just.
# 1. Bring the reference stack up (Keycloak, Valkey, APISIX, Auth Service, Resource Server).
just up # or, without just: sh scripts/up.sh
# 2. Start the SPA dev server.
cd frontend && npm install && npm run dev- SPA: http://127.0.0.1:5173/, sign in as
alice/alice. - Keycloak admin: http://localhost:8080/,
admin/adminto inspect the seeded realm.
Verify it
just e2e-auth # authenticated proof: login → API → refresh → logout
just e2e-portability # same code against a second realm (IdP portability)
sh scripts/verify-all.sh # per-component checks + secret scan
RUN_FULL_STACK_AUTH=1 sh scripts/verify-all.sh # the above, plus full stack + gateway suiteOAuth/OIDC vocabulary, mapped to this repo's components.
| Term | Meaning |
|---|---|
| OIDC | OpenID Connect, the identity layer on top of OAuth 2.0. |
| Relying Party (RP) | The app that delegates login to an identity provider. Here, the Auth Service. |
| Authorization Server (AS) | The service that authenticates the user and issues tokens. Here, Keycloak. |
| Identity Provider (IdP) | The Authorization Server in its identity role; used interchangeably here. |
| Resource Server (RS) | The API that validates access tokens and serves data. Here, backend-resource-server. |
| BFF | Backend-for-Frontend; the server-side component that holds tokens so the browser never does. |
sid / session cookie |
The sid is the opaque session identifier; the server keys the record on it (sess:{sid}). The browser carries the sid in __Host-sid, its only credential. The cookie is the envelope; the sid is the value inside. |
| PKCE | Proof Key for Code Exchange; binds an authorization code to the client that began the flow. |
| JWT / JWKS | JSON Web Token / JSON Web Key Set (the public keys that verify a JWT signature). |
| CSRF / XSS | Cross-Site Request Forgery / Cross-Site Scripting. |
| SPA | Single-page application; the browser app (here, React). |
| acr / LoA | Authentication Context Class Reference / Level of Assurance; how strongly the user authenticated. |
| SSO | Single sign-on. |
docs/specs/SPEC-0001-core-oidc-flows.md: the build contract. Wire formats forsess:{sid},tx:{state},/internal/resolve, signed CSRF; threat model; trust boundaries. Appendix A is the vendor-swap matrix.docs/architecture/architecture-decisions.md: rationale + rejected alternatives.SECURITY.md: threat model, crypto primitives, key handling, audit-logging surface, production-hardening list, vulnerability reporting.OIDC-compliance.md: conformance matrix against OpenID Connect Core 1.0 + Discovery + RP-Initiated Logout.RFC9700-compliance.md: control-by-control status against RFC 9700 (OAuth 2.0 Security BCP / OAuth 2.1 baseline).RFC9470-compliance.md: control-by-control status against RFC 9470 (Step-Up Authentication Challenge).docs/reference/refresh-rotation.md: refresh-token rotation policy and theapp.refresh-require-rotationknob.docs/operations/provider-adapters.md: IdP swap walkthrough (Keycloak / Auth0 / Okta / Entra).docs/operations/production-hardening.md: the gap list between this local reference and a real deployment.AGENTS.md: contributor operating contract.