15. PKCE S256 and session cookie
Date: 2026-03-17
Status
Accepted
Context
The proxy needs to support browser-based authentication via OIDC authorization code flow. This requires:
- PKCE (Proof Key for Code Exchange, RFC 7636) to secure the authorization code exchange for public clients (browser apps cannot keep a client secret)
- Session persistence to avoid re-authenticating on every request after the initial login
Security requirements
- The authorization code exchange must be protected against interception and replay attacks
- Session tokens must be tamper-proof and time-limited
- The flow must work without storing secrets in the browser (no client_secret in JavaScript)
Decision
PKCE: S256 only
We implement PKCE with S256 challenge method only — the plain method is explicitly rejected.
class PKCEChallenge:
code_verifier: str # 43-128 chars, cryptographically random
code_challenge: str # BASE64URL(SHA256(verifier))
method: str = "S256" # Only S256 — validated in __post_init__
Rationale: RFC 7636 Section 7.2 recommends S256 over plain. The plain method provides no security benefit over not using PKCE at all — it transmits the verifier in the clear. Since we control both sides of the flow, there is no reason to support plain.
PKCE state management
The PKCE code verifier is stored server-side in an InMemoryPKCEStore, keyed by a cryptographically random state parameter:
| Property | Value | Rationale |
|---|---|---|
| Storage | In-memory dict | No external dependency; sufficient for single-instance proxy |
| Key | state (32 bytes, base64url) |
CSRF protection + lookup key |
| Retrieve | Atomic delete-on-read | Prevents replay — each state can only be used once |
| TTL | Configurable (default 600s) | Limits window for code exchange |
| Max entries | Configurable (default 1000) | Prevents memory exhaustion; oldest-first eviction |
| Concurrency | anyio.Lock |
Safe under asyncio and trio |
Session cookie: HMAC-SHA256
After successful token exchange, the access token is stored in an HMAC-signed cookie:
cookie_value = BASE64URL(JSON(payload)) + "." + HMAC-SHA256(payload, secret_key)
| Property | Value | Rationale |
|---|---|---|
| Integrity | HMAC-SHA256 | Tamper-proof — any modification invalidates the signature |
| Confidentiality | None (readable) | Access tokens are bearer tokens; the cookie adds transport-level integrity, not secrecy |
| HttpOnly | true |
Prevents JavaScript access (XSS mitigation) |
| Secure | Configurable (default true) |
HTTPS only in production; set SESSION_SECURE=false for plain-HTTP local development. Hardcoding true causes browsers to silently discard the cookie over HTTP, breaking local dev. |
| SameSite | Configurable (default lax) |
CSRF protection while allowing top-level navigation |
| Max-Age | Configurable (default 86400s) | Time-limited session |
| Timestamp | Embedded _ts field |
Server-side TTL enforcement independent of browser cookie expiry |
Why not encrypted cookies?
The cookie contains an access token that is already opaque to the browser. HMAC provides integrity (no tampering) which is the critical property. Encryption would add complexity (key management, IV rotation) without meaningful security benefit — the access token is usable by anyone who possesses it regardless of how they obtained it.
Auth routes
Four Starlette routes handle the browser flow:
| Route | Method | Action |
|---|---|---|
/auth/login |
GET | Generate PKCE + state, store verifier, redirect to OIDC provider |
/auth/callback |
GET | Validate state, exchange code + verifier for tokens, set cookie, redirect to / |
/auth/refresh |
POST | Refresh tokens using refresh token from session cookie |
/auth/logout |
POST | Delete session cookie (set Max-Age=0) |
Domain / adapter separation
The OIDC flow logic is split between domain and adapter layers:
| Layer | Component | Responsibility |
|---|---|---|
| Domain | PKCEChallenge, OIDCAuthorizationRequest, OIDCCallback, TokenExchangeResult |
Immutable value objects with validation |
| Domain | generate_pkce_challenge(), generate_random_state() |
Pure cryptographic generation (stdlib only) |
| Service | OIDCFlowService |
Orchestrates initiate/complete — no HTTP, no framework |
| Adapter | InMemoryPKCEStore |
State storage with TTL + eviction |
| Adapter | CognitoOIDCClient |
Token exchange via httpx.AsyncClient |
| Adapter | SessionCookie |
HMAC sign/verify |
| Adapter | AuthRouteHandler |
Starlette route handlers |
Alternatives considered
JWT-based session (signed JWT in cookie)
Store a self-contained JWT as the session cookie. Rejected because: - Adds JWT signing complexity on the proxy side - Access tokens from the provider are already JWTs — wrapping a JWT in another JWT adds no value - HMAC-signed JSON is simpler and sufficient
Server-side session store (Redis/database)
Store session data server-side with a session ID cookie. Rejected because: - Adds external dependency (Redis) for a single-instance proxy - The access token is small enough to store in a cookie - Can be added later if horizontal scaling requires it
plain PKCE method support
Support both plain and S256. Rejected because:
- plain provides no security over not using PKCE
- RFC 7636 recommends S256
- Supporting plain increases attack surface with zero benefit
Consequences
- PKCE S256 protects the authorization code exchange against interception
- Atomic delete-on-read prevents state replay attacks
- HMAC-SHA256 cookie prevents session tampering without encryption complexity
InMemoryPKCEStorelimits the proxy to single-instance deployment (acceptable for current requirements)- All configuration is via environment variables (
PKCE_*,SESSION_*,COGNITO_DOMAIN,COGNITO_CALLBACK_URI) - The
OIDCFlowServiceis framework-agnostic — testable with stubs, no Starlette dependency
References
- RFC 7636 — PKCE — Proof Key for Code Exchange
- ADR-0014 Async Ports for OIDC — Async ports rationale
- ADR-0013 Provider-agnostic JWT Validation — OIDC adapter architecture
- ADR-0012 Environment-based Adapter Selection — Wiring priority