Skip to content

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:

  1. PKCE (Proof Key for Code Exchange, RFC 7636) to secure the authorization code exchange for public clients (browser apps cannot keep a client secret)
  2. 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

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

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
  • InMemoryPKCEStore limits the proxy to single-instance deployment (acceptable for current requirements)
  • All configuration is via environment variables (PKCE_*, SESSION_*, COGNITO_DOMAIN, COGNITO_CALLBACK_URI)
  • The OIDCFlowService is framework-agnostic — testable with stubs, no Starlette dependency

References