This is an old revision of the document!
Table of Contents
OIDC SSO System — Traditional Web Application + JWT + JWKS
Document Version: 1.0 Last Updated: 2026-06-16 Scope: Single Sign-On architecture for server-rendered / traditional web applications
using Authorization Code Flow (confidential client), JWT access tokens,
JWKS-based verification, and server-side session management.
Table of Contents
Architecture Overview
This document describes a server-side SSO system where:
- The Web application is a traditional server-rendered app (PHP, Java, Python/Django, Node/Express, .NET, Ruby on Rails, etc.)
- The web app is a confidential client — it has a
client_secretstored securely on the server - The browser never sees tokens directly — they are stored server-side in a session
- ServerA and ServerB are backend microservices/APIs called server-to-server (not directly from browser)
- JWT validation on all resource servers uses JWKS — no SSO contact per request
┌──────────────────────────────────────────────────────────────────────────┐ │ SYSTEM TOPOLOGY │ │ │ │ Browser │ │ ┌────────┐ HTTP + Session Cookie ┌─────────────────────────────┐ │ │ │ User │◀──────────────────────────▶│ Web App │ │ │ │(Browser│ │ (Confidential OIDC Client) │ │ │ └────────┘ │ - Renders HTML pages │ │ │ │ - Holds tokens server-side │ │ │ │ - Calls ServerA / ServerB │ │ │ └──────────────┬──────────────┘ │ │ │ │ │ ┌────────────────────────┼────────────────┐ │ │ │ │ │ │ │ ▼ ▼ │ │ │ ┌──────────────────┐ ┌──────────────────┐ │ │ │ │ ServerA │ │ ServerB │ │ │ │ │ (Resource API) │ │ (Resource API) │ │ │ │ │ JWT via JWKS │ │ JWT via JWKS │ │ │ │ └──────────────────┘ └──────────────────┘ │ │ │ │ │ │ ┌──────────────────────────────────────┐ │ │ │ │ ServerSSO │ │ │ │ │ (OIDC Identity Provider) │ │ │ │ │ /authorize /token /jwks.json │ │ │ │ └──────────────────────────────────────┘ │ │ └────────────────────────────────────────────────────────────────────────┘
Key Differences from SPA/PKCE
| Aspect | SPA + PKCE | Traditional Web + Confidential Client |
|---|---|---|
| Client type | Public (no secret) | Confidential (has client_secret) |
| PKCE | Required | Optional (client_secret replaces it) |
| Token storage | Browser memory / sessionStorage | Server-side session (never in browser) |
| Token exchange | Browser calls /token directly | Web server backend calls /token |
| API calls | Browser → API (Bearer JWT) | Web server → API (Bearer JWT) |
| Session management | JS state + localStorage | HTTP session cookie (HttpOnly, Secure) |
| Logout complexity | Clear JS state + redirect | Invalidate session + SSO logout |
| Security surface | XSS can steal tokens | XSS cannot access tokens (server-side) |
| Back-channel logout | Complex (postMessage) | Easy (server receives logout notification) |
Security advantage of traditional web apps: Because tokens never reach the browser,
XSS attacks cannot steal access_tokens or refresh_tokens. The browser only ever holds
a session cookie (HttpOnly + Secure + SameSite=Lax).
Components
Web Application (Traditional / Server-Rendered)
| Property | Value |
|---|---|
| Role | OIDC Relying Party (RP) — Confidential Client |
| Client Type | Confidential (has client_secret) |
| Auth Method | client_secret_basic or client_secret_post |
| Token Storage | Server-side session store (Redis / DB) |
| Session Cookie | HttpOnly, Secure, SameSite=Lax |
| Base URL | https://web.example.com |
| Redirect URI | https://web.example.com/auth/callback |
| Stack examples | PHP/Laravel, Java/Spring, Python/Django, .NET |
ServerSSO (Identity Provider / Authorization Server)
| Property | Value |
|---|---|
| Role | OIDC Provider (OP) / Authorization Server |
| Standard | OpenID Connect Core 1.0 |
| Token Format | JWT (RS256) |
| Base URL | https://sso.example.com |
| Discovery URL | https://sso.example.com/.well-known/openid-configuration |
| JWKS URL | https://sso.example.com/.well-known/jwks.json |
| Token Endpoint | https://sso.example.com/token |
ServerA (Resource Server / Microservice)
| Property | Value |
|---|---|
| Role | OAuth 2.0 Resource Server |
| Called by | Web App backend (server-to-server) |
| Validates | JWT Bearer token via JWKS |
| Required Scope | api:serverA |
| Base URL | https://api-a.example.com |
| Network | Internal / private network preferred |
ServerB (Resource Server / Microservice)
| Property | Value |
|---|---|
| Role | OAuth 2.0 Resource Server |
| Called by | Web App backend (server-to-server) |
| Validates | JWT Bearer token via JWKS |
| Required Scope | api:serverB |
| Base URL | https://api-b.example.com |
OIDC Authorization Code Flow — Confidential Client
Flow Overview
1. User visits protected page → Web App detects no session 2. Web App redirects browser → ServerSSO /authorize 3. ServerSSO authenticates user (login page, MFA, etc.) 4. ServerSSO redirects browser → Web App /callback?code=... 5. Web App backend calls ServerSSO /token (with client_secret) ← server-to-server 6. ServerSSO returns access_token, id_token, refresh_token 7. Web App stores tokens in server-side session 8. Web App sets session cookie on browser (HttpOnly) 9. Web App calls ServerA/ServerB with Bearer JWT ← server-to-server 10. ServerA/B validate JWT via JWKS (offline, no SSO contact) 11. Web App renders response to browser
client_secret Authentication
When the Web App exchanges a code for tokens, it authenticates itself to the SSO server
using its client_secret. This is what distinguishes a confidential client from a public client.
Method 1 — HTTP Basic Auth (client_secret_basic): Authorization: Basic base64(client_id:client_secret) Method 2 — POST body (client_secret_post): client_id=web-app-001&client_secret=s3cr3t-v4lue...
The SSO server trusts this client to:
- Exchange codes directly (no PKCE required, though it can be added)
- Store and use refresh_tokens securely
- Not expose tokens to end-users
JWT & JWKS Explained
JWT Structure
A JWT = header.payload.signature (all base64url-encoded)
Decoded Header:
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-2024-01" ← Key ID — matches a key in JWKS
}
Decoded Payload (access_token):
{
"iss": "https://sso.example.com",
"sub": "user-uid-456",
"aud": ["https://api-a.example.com", "https://api-b.example.com"],
"exp": 1718550000,
"iat": 1718549100,
"nbf": 1718549100,
"jti": "unique-token-id-abc",
"email": "alice@example.com",
"name": "Alice Martin",
"scope": "openid profile email api:serverA api:serverB",
"roles": ["user", "editor"],
"sid": "sso-session-xyz" ← SSO session ID (for backchannel logout)
}
Decoded Payload (id_token):
{
"iss": "https://sso.example.com",
"sub": "user-uid-456",
"aud": "web-app-001", ← id_token audience = client_id
"exp": 1718549400,
"iat": 1718549100,
"nonce": "random-nonce-xyz", ← Replay protection for id_token
"email": "alice@example.com",
"name": "Alice Martin"
}
JWKS Endpoint
// GET https://sso.example.com/.well-known/jwks.json
// Response cached by resource servers (1 hour TTL)
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "key-2024-01",
"alg": "RS256",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWh...",
"e": "AQAB"
}
]
}
JWT Validation on Resource Servers
Resource Server (ServerA or ServerB) validates each incoming JWT:
1. Parse JWT header → extract "kid"
2. Look up JWKS cache → find key where key.kid == header.kid
3. If not found in cache → re-fetch /jwks.json → update cache → retry
4. Verify RSA signature using the public key
5. Check: payload.iss == "https://sso.example.com"
6. Check: payload.aud includes this server's URL
7. Check: payload.exp > current_unix_timestamp
8. Check: payload.nbf <= current_unix_timestamp (± clock_skew)
9. Check: payload.iat is not in the future (± clock_skew)
10. Check: payload.scope includes required scope ("api:serverA" / "api:serverB")
11. Optionally check: payload.jti not in revocation list
12. Extract sub, email, roles for business logic
Critical: ServerA and ServerB NEVER contact ServerSSO during request validation.
JWT validation is entirely offline using the cached RSA public key from JWKS.
The SSO server is only contacted for JWKS key rotation (every ~1 hour or on unknown kid).
Session Management
Server-Side Session Store
The Web App maintains a session store (Redis, database, or encrypted cookie). The browser only sees a session cookie — never the JWT.
Browser Cookie:
Set-Cookie: session_id=abc123xyz; HttpOnly; Secure; SameSite=Lax; Path=/
Server-Side Session Record (Redis / DB):
{
"session_id": "abc123xyz",
"user_id": "user-uid-456",
"email": "alice@example.com",
"name": "Alice Martin",
"roles": ["user", "editor"],
"access_token": "eyJhbGci...", ← JWT (stored server-side only)
"access_token_exp": 1718550000,
"refresh_token": "8xLOxBtZp8...",
"id_token": "eyJhbGci...",
"sso_session_id": "sso-session-xyz", ← for backchannel logout
"created_at": 1718549100,
"last_seen": 1718549100
}
Session vs Token Lifecycle
Timeline:
├── User logs in ──────────────────────────────────────────────────────▶
│
│ access_token expires: +15 min → Web App refreshes silently
│ refresh_token expires: +24 hrs → Web App must re-login user
│ Web session expires: +8 hrs → Configurable inactivity timeout
│ SSO session expires: +8 hrs → Configurable at SSO server
│
└── User logs out ─────────────────────────────────────────────────────▶
1. Web App deletes server-side session
2. Clears session cookie
3. Calls SSO /logout (terminates SSO session)
4. SSO triggers backchannel logout to all registered clients
Scenario 1: Unauthenticated User
Overview
Alice opens her browser and navigates to https://web.example.com/dashboard.
She has no active session. The system must authenticate her and then serve the page.
Step 1 — Browser Requests Protected Page
Browser → Web App: GET https://web.example.com/dashboard Cookie: (none / no valid session cookie)
Step 2 — Web App Detects No Session
# Web App middleware (Python/Django example) def require_auth(view_func): def wrapper(request): session_id = request.COOKIES.get('session_id') session = session_store.get(session_id) if session_id else None if not session or is_expired(session): # No valid session → initiate OIDC login return initiate_oidc_login(request) request.user = session['user'] return view_func(request) return wrapper
What happens: No session found (or session expired). The Web App:
- Generates a random
statevalue (CSRF protection) - Generates a random
noncevalue (id_token replay protection) - Stores both in a short-lived pre-auth cookie or server-side temp store
- Builds the /authorize URL
- Returns HTTP 302 to the browser
Step 3 — Web App Redirects Browser to ServerSSO /authorize
Web App → Browser:
HTTP 302
Location: https://sso.example.com/authorize
?response_type=code
&client_id=web-app-001
&redirect_uri=https://web.example.com/auth/callback
&scope=openid profile email api:serverA api:serverB
&state=csrf-token-f3a8b2
&nonce=replay-token-9d4e1c
Parameters:
| Parameter | Value | Purpose |
|---|---|---|
| response_type | code | Request authorization code |
| client_id | web-app-001 | Identifies the Web App |
| redirect_uri | https://web.example.com/auth/callback | Where SSO sends the code |
| scope | openid profile email api:serverA api:serverB | Requested permissions |
| state | Random CSRF token | Prevents CSRF on callback |
| nonce | Random value | Embedded in id_token to prevent replay |
Note: No PKCE parameters. The confidential client authenticates viaclient_secret
at the token endpoint instead. PKCE can be added as defense-in-depth.
Step 4 — Browser Follows Redirect to ServerSSO
Browser → ServerSSO: GET https://sso.example.com/authorize?response_type=code&client_id=... Cookie: (no SSO session cookie)
ServerSSO sees no active SSO session → presents login page.
Step 5 — ServerSSO Presents Login Page
ServerSSO → Browser:
HTTP 200
Content-Type: text/html
Set-Cookie: sso_pre_session=temp123; HttpOnly; Secure; SameSite=Lax
<html>
<form method="POST" action="/login">
<input name="username" type="text" />
<input name="password" type="password" />
<button type="submit">Sign In</button>
</form>
</html>
Step 6 — Alice Submits Credentials
Browser → ServerSSO: POST https://sso.example.com/login Content-Type: application/x-www-form-urlencoded Cookie: sso_pre_session=temp123 username=alice%40example.com&password=secret123
ServerSSO processes:
- Validates credentials against user store (LDAP, database, etc.)
- If MFA configured: presents second factor challenge
- On success: creates SSO session, sets SSO session cookie
ServerSSO → Browser: HTTP 302 Set-Cookie: sso_session=SESSION-XYZ; HttpOnly; Secure; SameSite=None; Domain=sso.example.com
Note: The SSO session cookie usesSameSite=Nonebecause it must be sent
in cross-origin redirects from the Web App domain to the SSO domain.
Step 7 — ServerSSO Issues Authorization Code and Redirects Back
ServerSSO → Browser:
HTTP 302
Location: https://web.example.com/auth/callback
?code=SplxlOBeZQQYbYS6WxSbIA
&state=csrf-token-f3a8b2
ServerSSO stores internally:
code "SplxlOBeZQQYbYS6WxSbIA" → {
client_id: "web-app-001",
redirect_uri: "https://web.example.com/auth/callback",
scope: "openid profile email api:serverA api:serverB",
nonce: "replay-token-9d4e1c",
user_id: "user-uid-456",
expires_at: now + 60s ← One-time, short-lived
}
Step 8 — Browser Follows Redirect to Web App Callback
Browser → Web App:
GET https://web.example.com/auth/callback
?code=SplxlOBeZQQYbYS6WxSbIA
&state=csrf-token-f3a8b2
Cookie: pre_auth_state=csrf-token-f3a8b2 ← set in Step 2
Step 9 — Web App Validates state (CSRF Check)
# Web App callback handler def auth_callback(request): code = request.GET.get('code') state = request.GET.get('state') # Retrieve stored state from pre-auth cookie / temp store stored_state = request.COOKIES.get('pre_auth_state') if not state or state != stored_state: raise SecurityError("State mismatch — possible CSRF attack") # Retrieve nonce for id_token validation later nonce = temp_store.get(f"nonce:{stored_state}") # Proceed to token exchange return exchange_code_for_tokens(code, nonce)
What is validated:
statein URL matchesstatestored in Step 2 → CSRF check ✅codeis present and non-empty
Step 10 — Web App Backend Exchanges Code for Tokens (Server-to-Server)
This call is server-to-server — the browser is NOT involved.
Web App Backend → ServerSSO: POST https://sso.example.com/token Content-Type: application/x-www-form-urlencoded Authorization: Basic d2ViLWFwcC0wMDE6czNjcjN0LXY0bHVl ← base64(client_id:client_secret) grant_type=authorization_code &code=SplxlOBeZQQYbYS6WxSbIA &redirect_uri=https://web.example.com/auth/callback
ServerSSO validates:
- Authorization header contains valid
client_id+client_secret codeis valid, not expired, not already usedredirect_uriexactly matches the one used in /authorizeclient_idin Authorization header matches the one the code was issued to
Step 11 — ServerSSO Returns JWT Tokens
ServerSSO → Web App Backend:
HTTP 200
Content-Type: application/json
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "8xLOxBtZp8QyNcZpA3Rx",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",
"scope": "openid profile email api:serverA api:serverB"
}
Step 12 — Web App Validates id_token and Stores Session
# Validate id_token id_token_payload = verify_jwt( token = token_response['id_token'], issuer = 'https://sso.example.com', audience = 'web-app-001', ← id_token audience = client_id nonce = stored_nonce, ← must match nonce from Step 3 algorithm = 'RS256', jwks_uri = 'https://sso.example.com/.well-known/jwks.json' ) # Create server-side session session_id = generate_secure_random_id() session_store.set(session_id, { 'user_id': id_token_payload['sub'], 'email': id_token_payload['email'], 'name': id_token_payload['name'], 'access_token': token_response['access_token'], 'access_token_exp': time.time() + token_response['expires_in'], 'refresh_token': token_response['refresh_token'], 'sso_session_id': id_token_payload.get('sid'), 'created_at': time.time() }, ttl=28800) # 8 hours
Step 13 — Web App Sets Session Cookie and Redirects to Original Page
Web App → Browser: HTTP 302 Location: https://web.example.com/dashboard ← original requested URL Set-Cookie: session_id=abc123xyz; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=28800
The browser now has only a session_id cookie — no JWT, no tokens.
All sensitive material is on the server.
Step 14 — Browser Requests Dashboard (Now Authenticated)
Browser → Web App: GET https://web.example.com/dashboard Cookie: session_id=abc123xyz
Step 15 — Web App Calls ServerA (Server-to-Server, Bearer JWT)
# Web App loads session, retrieves access_token session = session_store.get('abc123xyz') access_token = session['access_token'] # Web App calls ServerA backend-to-backend response = http_client.get( 'https://api-a.example.com/api/data', headers={ 'Authorization': f'Bearer {access_token}', 'X-Request-ID': generate_request_id() } )
This is a server-to-server call — the browser is NOT involved.
Step 16 — ServerA Validates JWT via JWKS (Offline)
# ServerA middleware def validate_bearer_token(request): auth_header = request.headers.get('Authorization', '') if not auth_header.startswith('Bearer '): return Response(401, {'error': 'missing_token'}) token = auth_header[7:] # Decode header to get kid header = decode_jwt_header(token) jwks = get_jwks_cached('https://sso.example.com/.well-known/jwks.json') pub_key = find_key(jwks, kid=header['kid']) if not pub_key: # Unknown kid → force refresh JWKS cache once jwks = fetch_jwks_live('https://sso.example.com/.well-known/jwks.json') pub_key = find_key(jwks, kid=header['kid']) if not pub_key: return Response(401, {'error': 'unknown_key'}) # Verify signature + standard claims payload = verify_jwt(token, pub_key, { 'issuer': 'https://sso.example.com', 'audience': 'https://api-a.example.com', 'algorithm': 'RS256' }) # Verify required scope if 'api:serverA' not in payload.get('scope', '').split(): return Response(403, {'error': 'insufficient_scope'}) request.user = payload # Continue to handler...
ServerSSO is NOT contacted here. Validation is pure cryptographic verification using the cached RSA public key.
Step 17 — ServerA Returns Data to Web App
ServerA → Web App:
HTTP 200
{
"items": [...],
"user": "alice@example.com"
}
Step 18 — Web App Calls ServerB (Same Pattern)
The Web App calls ServerB with the same access_token:
Web App Backend → ServerB: GET https://api-b.example.com/api/records Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...
ServerB performs the same JWKS-based JWT validation, checking:
audincludeshttps://api-b.example.comscopeincludesapi:serverB
Step 19 — Web App Renders Final HTML Response
- App → Browser: HTTP 200 Content-Type: text/html <html
<body> <h1>Dashboard</h1> <p>Welcome, Alice Martin</p> <!-- Data from ServerA and ServerB merged into page --> </body> </html>
The browser receives only rendered HTML. No tokens appear in the browser.
Scenario 1 — Complete Flow Summary
Browser Web App ServerSSO ServerA ServerB
│ │ │ │ │
│─ GET /dash ──▶│ │ │ │
│ │ no session │ │ │
│◀─ 302 /auth ─│ │ │ │
│ │ │ │
│─ GET /authorize ───────────────▶│ │ │
│◀─ 302 /login ──────────────────│ │ │
│─ POST /login ──────────────────▶│ │ │
│◀─ SSO session cookie ───────────│ │ │
│◀─ 302 /callback?code= ─────────│ │ │
│ │ │ │
│─ GET /callback?code= ─────────▶│ │ │
│ │─ POST /token ──▶│ │ │
│ │ (+ client_secret, s2s) │ │
│ │◀─ {tokens} ────│ │ │
│ │ store session │ │ │
│◀─ 302 /dash + session_id cookie │ │ │
│ │ │ │
│─ GET /dash ──▶│ │ │ │
│ (session cookie) │ │ │
│ │─ GET /api ──────────────────────▶│ │
│ │ (Bearer JWT, s2s) │ │
│ │ validate JWT (JWKS offline) │ │
│ │◀─ {data} ────────────────────────│ │
│ │─ GET /api ─────────────────────────────────────▶│
│ │ (Bearer JWT, s2s) │
│ │ validate JWT (JWKS offline) │
│ │◀─ {data} ───────────────────────────────────────│
│◀─ 200 HTML ──│ │ │ │
Scenario 2: Authenticated User
Overview
Alice already has an active session from a previous login. She navigates to multiple pages, triggering calls to ServerA and ServerB. This scenario covers:
- 2A — Session and access_token both valid → serve directly
- 2B — Session valid but access_token expired → silent refresh
- 2C — Session expired / invalidated → re-authentication
Scenario 2A — Session Active, Token Valid
Step 1 — Browser Requests Page with Session Cookie
Browser → Web App: GET https://web.example.com/reports Cookie: session_id=abc123xyz
Step 2 — Web App Validates Session
def require_auth(request): session_id = request.COOKIES.get('session_id') session = session_store.get(session_id) if not session: return redirect_to_login(request) # No session # Check access_token expiry (with 60s buffer) if session['access_token_exp'] < time.time() + 60: session = refresh_access_token(session) # → Scenario 2B request.user = session request.token = session['access_token'] # Continue to handler
Step 3 — Web App Calls ServerA and ServerB
Web App → ServerA: GET /api/reports (Authorization: Bearer JWT) Web App → ServerB: GET /api/metrics (Authorization: Bearer JWT)
Both servers validate JWT offline via JWKS. The same JWT is reused for the duration of its validity.
Step 4 — Web App Renders Page
No SSO interaction. No new tokens. Pure session lookup + backend API calls.
Browser Web App ServerSSO ServerA ServerB
│ │ │ │ │
│─ GET /reports─▶│ │ │ │
│ (session cookie) │ │ │
│ │─ GET /api/reports ──────────────▶│ │
│ │◀─ {data} ────────────────────────│ │
│ │─ GET /api/metrics ────────────────────────────▶│
│ │◀─ {data} ───────────────────────────────────────│
│◀─ 200 HTML ──│ │ │ │
Scenario 2B — Session Valid, Access Token Expired
Step 1 — Web App Detects Expired Token
# Session is valid but access_token is expired if session['access_token_exp'] < time.time() + 60: new_tokens = refresh_access_token(session['refresh_token']) if new_tokens: # Update session with new tokens session['access_token'] = new_tokens['access_token'] session['access_token_exp'] = time.time() + new_tokens['expires_in'] session['refresh_token'] = new_tokens['refresh_token'] # Rotated session_store.update(session_id, session) else: # Refresh failed → force re-login return redirect_to_login(request)
Step 2 — Web App Calls ServerSSO /token with Refresh Token (Server-to-Server)
This call is entirely transparent to the user. No redirect, no interruption.
Web App Backend → ServerSSO: POST https://sso.example.com/token Content-Type: application/x-www-form-urlencoded Authorization: Basic d2ViLWFwcC0wMDE6czNjcjN0LXY0bHVl grant_type=refresh_token &refresh_token=8xLOxBtZp8QyNcZpA3Rx
Step 3 — ServerSSO Returns New Tokens
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...(new)",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "9yMpaBuA3RxNewToken", ← Rotated (old one invalidated)
"scope": "openid profile email api:serverA api:serverB"
}
Step 4 — Web App Updates Session Transparently
# Update session — user is completely unaware this happened session_store.update(session_id, { 'access_token': new_tokens['access_token'], 'access_token_exp': time.time() + new_tokens['expires_in'], 'refresh_token': new_tokens['refresh_token'] }) # Continue to serve the original request
Step 5 — Web App Continues with New Token
Browser Web App ServerSSO ServerA
│ │ │ │
│─ GET /reports─▶│ │ │
│ (session OK, │ │ │
│ token expired│─ POST /token ──▶│ │
│ │ (refresh, s2s) │ │
│ │◀─ new tokens ───│ │
│ │ update session │ │
│ │─ GET /api ───────────────────────▶│
│ │ (new Bearer JWT)│ │
│ │◀─ {data} ────────────────────────│
│◀─ 200 HTML ──│ │ │
The user experiences zero interruption. The token refresh is completely transparent.
Scenario 2C — Session Expired (Re-authentication Required)
Step 1 — Web App Finds Expired / Missing Session
def require_auth(request): session_id = request.COOKIES.get('session_id') session = session_store.get(session_id) # Session expired or doesn't exist if not session or session_is_expired(session): # Save intended URL for post-login redirect original_url = request.build_absolute_uri() request.session['post_login_redirect'] = original_url return redirect_to_oidc_login(request)
Step 2 — Web App Redirects to SSO
Same as Scenario 1 Step 3. But this time:
- If the SSO session is still active (e.g., user logged in via another app) → SSO skips login page and immediately returns an authorization code
- If the SSO session also expired → SSO shows the login page again
SSO session still active (seamless re-auth): Browser → ServerSSO: GET /authorize ServerSSO sees valid SSO session cookie ServerSSO → Browser: 302 /callback?code=... (no login page shown) SSO session also expired: Browser → ServerSSO: GET /authorize ServerSSO sees no valid session ServerSSO → Browser: login page Alice logs in again → proceeds normally
This is the SSO effect — if Alice has multiple apps sharing the same SSO server, she only needs to log in once. Re-authentication in any individual app is seamless as long as the SSO session is still active.
Step 3 — New Session Created, Original URL Restored
# After successful token exchange (same as Scenario 1) # Redirect to original URL the user wanted original_url = request.session.pop('post_login_redirect', '/dashboard') return redirect(original_url)
Scenario 2 — Complete Flow Summary
Browser Web App ServerSSO ServerA ServerB
│ │ │ │ │
│ [2A: Session and token valid] │ │ │
│─ GET /page ──▶│ │ │ │
│ │─ GET /api ──────────────────────▶│ │
│ │◀─ {data} ────────────────────────│ │
│◀─ 200 HTML ──│ │ │ │
│ │ │ │ │
│ [2B: Session valid, token expired] │ │
│─ GET /page ──▶│ │ │ │
│ │─ POST /token ──▶│ │ │
│ │ (refresh, s2s) │ │ │
│ │◀─ new tokens ───│ │ │
│ │─ GET /api ──────────────────────▶│ │
│ │◀─ {data} ────────────────────────│ │
│◀─ 200 HTML ──│ │ │ │
│ │ │ │ │
│ [2C: Session expired] │ │ │
│─ GET /page ──▶│ │ │ │
│ │ no session │ │ │
│◀─ 302 ───────│ │ │ │
│─ GET /authorize ───────────────▶│ │ │
│ SSO session still active │ │ │
│◀─ 302 /callback?code= ─────────│ │ │
│─ GET /callback?code= ─────────▶│ │ │
│ │─ POST /token ──▶│ │ │
│ │◀─ {tokens} ────│ │ │
│◀─ 302 /page + session cookie ──│ │ │
│─ GET /page ──▶│ │ │ │
│ │─ GET /api ──────────────────────▶│ │
│ │◀─ {data} ────────────────────────│ │
│◀─ 200 HTML ──│ │ │ │
Sequence Diagrams
Full Authorization Code Flow (Confidential Client)
Step-by-step:
1. Browser → Web App : GET /protected-page (no session cookie)
2. Web App → Web App : generate state, nonce; store temporarily
3. Web App → Browser : HTTP 302 → /authorize?...&state=X&nonce=Y
4. Browser → ServerSSO : GET /authorize?response_type=code&client_id=...
5. ServerSSO → Browser : 200 login page (no SSO session)
6. Browser → ServerSSO : POST /login (username + password)
7. ServerSSO → ServerSSO : validate credentials, create SSO session
8. ServerSSO → Browser : Set-Cookie: sso_session + 302 → /callback?code=
9. Browser → Web App : GET /callback?code=AUTH_CODE&state=X
10. Web App → Web App : validate state == stored state (CSRF check)
11. Web App → ServerSSO : POST /token (code + client_secret) [s2s]
12. ServerSSO → ServerSSO : verify client_secret, validate code
13. ServerSSO → Web App : { access_token, id_token, refresh_token }
14. Web App → Web App : validate id_token (nonce, aud, exp, sig via JWKS)
15. Web App → SessionDB : store { user, tokens } under session_id
16. Web App → Browser : Set-Cookie: session_id=XYZ + 302 → /page
17. Browser → Web App : GET /page (Cookie: session_id=XYZ)
18. Web App → SessionDB : get session → retrieve access_token
19. Web App → ServerA : GET /api (Bearer JWT) [s2s]
20. ServerA → JWKS Cache : verify JWT (RS256, offline)
21. ServerA → Web App : 200 { data }
22. Web App → ServerB : GET /api (same Bearer JWT) [s2s]
23. ServerB → JWKS Cache : verify JWT (RS256, offline)
24. ServerB → Web App : 200 { data }
25. Web App → Browser : 200 HTML page (data merged)
API Contracts
ServerSSO Endpoints
GET /.well-known/openid-configuration
{
"issuer": "https://sso.example.com",
"authorization_endpoint": "https://sso.example.com/authorize",
"token_endpoint": "https://sso.example.com/token",
"userinfo_endpoint": "https://sso.example.com/userinfo",
"jwks_uri": "https://sso.example.com/.well-known/jwks.json",
"end_session_endpoint": "https://sso.example.com/logout",
"backchannel_logout_supported": true,
"backchannel_logout_session_supported": true,
"response_types_supported": ["code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "profile", "email", "api:serverA", "api:serverB"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
"claims_supported": ["sub", "email", "name", "roles", "sid"]
}
POST /token — Authorization Code
Request (client_secret_basic):
POST /token HTTP/1.1 Host: sso.example.com Content-Type: application/x-www-form-urlencoded Authorization: Basic d2ViLWFwcC0wMDE6czNjcjN0LXY0bHVl grant_type=authorization_code &code=SplxlOBeZQQYbYS6WxSbIA &redirect_uri=https://web.example.com/auth/callback
Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "8xLOxBtZp8QyNcZpA3Rx",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",
"scope": "openid profile email api:serverA api:serverB"
}
POST /token — Refresh Token
POST /token HTTP/1.1 Authorization: Basic d2ViLWFwcC0wMDE6czNjcjN0LXY0bHVl grant_type=refresh_token &refresh_token=8xLOxBtZp8QyNcZpA3Rx
POST /backchannel_logout
ServerSSO calls this endpoint on the Web App when a user logs out from any session:
POST https://web.example.com/auth/backchannel-logout Content-Type: application/x-www-form-urlencoded logout_token=eyJhbGciOiJSUzI1NiJ9...
Logout token payload:
{
"iss": "https://sso.example.com",
"sub": "user-uid-456",
"aud": "web-app-001",
"iat": 1718549100,
"jti": "unique-logout-jti",
"sid": "sso-session-xyz", ← SSO session ID → find all web sessions for this SSO session
"events": {
"http://schemas.openid.net/event/backchannel-logout": {}
}
}
# Web App backchannel logout handler def backchannel_logout(request): logout_token = request.POST.get('logout_token') payload = verify_logout_token(logout_token) # validate sig, iss, aud sso_sid = payload.get('sid') sub = payload.get('sub') # Delete all web sessions associated with this SSO session session_store.delete_by_sso_session(sso_sid) return Response(200) # Must respond 200 to acknowledge
Error Responses
| HTTP | error | Description |
|---|---|---|
| 400 | invalid_request | Missing or malformed parameter |
| 400 | invalid_grant | Code or refresh_token invalid / expired |
| 401 | invalid_client | client_id or client_secret incorrect |
| 403 | insufficient_scope | Requested scope not allowed for this client |
| 400 | unsupported_grant_type | grant_type not recognized |
ServerA & ServerB Protected Endpoint Contract
Request:
GET /api/data HTTP/1.1
Host: api-a.example.com
Authorization: Bearer {access_token_jwt}
X-Request-ID: {uuid}
Error Responses:
| HTTP | WWW-Authenticate header | Meaning |
|---|---|---|
| 401 | Bearer error=“missing_token” | No Authorization header |
| 401 | Bearer error=“invalid_token” | JWT malformed or signature invalid |
| 401 | Bearer error=“token_expired” | JWT exp in the past |
| 403 | Bearer error=“insufficient_scope” | Scope check failed |
| 403 | Bearer error=“invalid_audience” | aud claim doesn't match this server |
Server-Side JWT Validation
Complete Validation Middleware (Python)
import jwt import requests import time from functools import lru_cache JWKS_URL = "https://sso.example.com/.well-known/jwks.json" ISSUER = "https://sso.example.com" SERVER_A_AUDIENCE = "https://api-a.example.com" CACHE_TTL = 3600 # 1 hour _jwks_cache = {'keys': {}, 'fetched_at': 0} def get_public_key(kid): """Retrieve RSA public key from JWKS cache, refresh if needed.""" global _jwks_cache # Check cache if kid in _jwks_cache['keys'] and \ time.time() - _jwks_cache['fetched_at'] < CACHE_TTL: return _jwks_cache['keys'][kid] # Refresh JWKS response = requests.get(JWKS_URL, timeout=5) jwks = response.json() _jwks_cache['fetched_at'] = time.time() _jwks_cache['keys'] = {} for key_data in jwks['keys']: public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key_data) _jwks_cache['keys'][key_data['kid']] = public_key return _jwks_cache['keys'].get(kid) def validate_token(token, required_audience, required_scope): """Validate JWT and return payload or raise an exception.""" # Decode header only (no verification) to get kid unverified_header = jwt.get_unverified_header(token) kid = unverified_header.get('kid') public_key = get_public_key(kid) if not public_key: raise ValueError(f"Unknown key id: {kid}") # Full verification payload = jwt.decode( token, public_key, algorithms=["RS256"], issuer=ISSUER, audience=required_audience, options={"require": ["exp", "iat", "nbf", "iss", "aud", "sub"]} ) # Scope check scopes = payload.get('scope', '').split() if required_scope not in scopes: raise PermissionError(f"Missing required scope: {required_scope}") return payload
Complete Validation Middleware (Java/Spring)
@Component public class JwtValidationFilter extends OncePerRequestFilter { private static final String ISSUER = "https://sso.example.com"; private static final String AUDIENCE = "https://api-a.example.com"; private static final String REQUIRED_SCOPE = "api:serverA"; private final JwkSet jwkSet; // Cached, refreshed hourly private final JwkSetCache cache; @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { String authHeader = req.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { res.sendError(401, "Missing Bearer token"); return; } String token = authHeader.substring(7); try { // 1. Parse header for kid SignedJWT signedJWT = SignedJWT.parse(token); String kid = signedJWT.getHeader().getKeyID(); // 2. Get key from cache (fetch from JWKS if unknown) RSAKey rsaKey = cache.getKey(kid); // 3. Verify signature RSASSAVerifier verifier = new RSASSAVerifier(rsaKey); if (!signedJWT.verify(verifier)) { res.sendError(401, "Invalid token signature"); return; } // 4. Validate claims JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); validateClaims(claims); // 5. Inject user into request context SecurityContextHolder.getContext() .setAuthentication(buildAuth(claims)); chain.doFilter(req, res); } catch (Exception e) { res.sendError(401, "Token validation failed: " + e.getMessage()); } } private void validateClaims(JWTClaimsSet claims) throws Exception { if (!ISSUER.equals(claims.getIssuer())) throw new Exception("Invalid issuer"); if (!claims.getAudience().contains(AUDIENCE)) throw new Exception("Invalid audience"); if (claims.getExpirationTime().before(new Date())) throw new Exception("Token expired"); if (!claims.getStringClaim("scope").contains(REQUIRED_SCOPE)) throw new Exception("Insufficient scope"); } }
Logout Flows
Standard Logout (User-Initiated)
1. User clicks "Logout" button on Web App
2. Browser → Web App:
POST https://web.example.com/logout
Cookie: session_id=abc123xyz
3. Web App:
a. Retrieve id_token from session
b. Delete server-side session from session store
c. Revoke refresh_token at SSO (optional but recommended)
4. Web App → Browser:
HTTP 302
Set-Cookie: session_id=; Max-Age=0; HttpOnly; Secure
Location: https://sso.example.com/logout
?id_token_hint=eyJhbGci...
&post_logout_redirect_uri=https://web.example.com/logged-out
&state=random-state-xyz
5. Browser → ServerSSO:
GET /logout?id_token_hint=...
6. ServerSSO:
a. Validates id_token_hint
b. Identifies SSO session
c. Terminates SSO session
d. Clears SSO session cookie
e. Triggers backchannel logout to all registered clients
f. Redirects to post_logout_redirect_uri
7. Browser → Web App:
GET https://web.example.com/logged-out
(session cookie already cleared)
8. Web App → Browser:
200 "You have been logged out"
Backchannel Logout (SSO-Initiated)
When a user logs out from another application sharing the same SSO:
ServerSSO → Web App Backend:
POST https://web.example.com/auth/backchannel-logout
(logout_token JWT identifying sub + sid)
Web App:
- Validates logout_token signature (JWKS)
- Finds all sessions matching sso_session_id (sid claim)
- Deletes those sessions from session store
- Returns HTTP 200
Result: User is silently logged out of the Web App even though
they only explicitly logged out from a different application.
Security Considerations
Cookie Security Requirements
| Attribute | Value | Reason |
|---|---|---|
| HttpOnly | true | JavaScript cannot read the session cookie |
| Secure | true | Cookie only sent over HTTPS |
| SameSite | Lax | CSRF protection for standard navigation |
| Path | / | Cookie sent to all app paths |
| Max-Age / Expires | 8 hours | Session timeout |
| Domain | web.example.com | Restrict to this app only |
client_secret Security
| Requirement | Implementation |
|---|---|
| Never hardcode in source code | Use environment variables / secret manager |
| Never log client_secret | Filter from all application logs |
| Rotate periodically | Minimum annually; immediately if leaked |
| Store in secrets manager | HashiCorp Vault, AWS Secrets Manager, etc. |
| Use TLS for all /token calls | Enforce HTTPS on SSO token endpoint |
Security Checklist
| # | Control | Description |
|---|---|---|
| 1 | state parameter validation | Always verify state matches stored value (CSRF) |
| 2 | nonce in id_token | Verify nonce to prevent id_token replay |
| 3 | Exact redirect_uri match | SSO rejects partial or wildcard URIs |
| 4 | client_secret confidentiality | Never expose to browser; store in secrets manager |
| 5 | Session cookie flags | HttpOnly + Secure + SameSite=Lax mandatory |
| 6 | Short access_token TTL | 15 minutes max; refresh transparently |
| 7 | Refresh token rotation | New token per use; old token invalidated |
| 8 | Server-side session store | Use Redis or DB; never JWT-only sessions |
| 9 | JWKS caching | 1 hour TTL; refetch on unknown kid only |
| 10 | Audience validation | Each server checks aud claim strictly |
| 11 | Backchannel logout | Implement to support global SSO logout |
| 12 | HTTPS everywhere | TLS 1.2+ on all endpoints |
Attack Mitigations
| Attack | Mitigation |
|---|---|
| CSRF on /callback | state parameter validated server-side |
| Token theft via XSS | Tokens never reach browser; session cookie is HttpOnly |
| Session fixation | New session_id after successful authentication |
| Code interception | client_secret required for /token; code single-use (60s) |
| Refresh token theft | Server-side storage; rotation on use; binding to client |
| Man-in-the-middle | Strict TLS everywhere; HSTS headers on Web App |
| id_token replay | nonce claim validated; short id_token TTL |
| Forged JWT | RS256 signature verified against JWKS public key |
| Backchannel logout skipped | logout_token signature verified before processing |
| Session after logout | Backchannel logout deletes all sessions for SSO session ID |
Configuration Reference
ServerSSO Client Registration
clients: - client_id: "web-app-001" client_secret: "${SSO_CLIENT_SECRET}" ← from secrets manager client_type: confidential redirect_uris: - "https://web.example.com/auth/callback" post_logout_redirect_uris: - "https://web.example.com/logged-out" backchannel_logout_uri: "https://web.example.com/auth/backchannel-logout" backchannel_logout_session_required: true allowed_scopes: - openid - profile - email - api:serverA - api:serverB token_endpoint_auth_method: client_secret_basic access_token_ttl: 900 # 15 minutes refresh_token_ttl: 86400 # 24 hours id_token_ttl: 300 # 5 minutes
Web Application Configuration
oidc: authority: "https://sso.example.com" client_id: "web-app-001" client_secret: "${SSO_CLIENT_SECRET}" redirect_uri: "https://web.example.com/auth/callback" scopes: - openid - profile - email - api:serverA - api:serverB token_endpoint_auth_method: client_secret_basic session: store: redis redis_url: "redis://session-redis:6379/0" ttl_seconds: 28800 # 8 hours cookie_name: session_id cookie_secure: true cookie_httponly: true cookie_samesite: Lax apis: server_a: "https://api-a.example.com" server_b: "https://api-b.example.com"
ServerA & ServerB JWT Validation Configuration
# ServerA jwt: issuer: "https://sso.example.com" audience: "https://api-a.example.com" algorithms: [RS256] jwks_uri: "https://sso.example.com/.well-known/jwks.json" jwks_cache_ttl: 3600 # 1 hour required_scope: "api:serverA" clock_skew: 30 # seconds tolerance # ServerB jwt: issuer: "https://sso.example.com" audience: "https://api-b.example.com" algorithms: [RS256] jwks_uri: "https://sso.example.com/.well-known/jwks.json" jwks_cache_ttl: 3600 required_scope: "api:serverB" clock_skew: 30
Quick Reference Card
╔══════════════════════════════════════════════════════════════════════╗ ║ OIDC TRADITIONAL WEB — QUICK REFERENCE ║ ╠══════════════════════════════════════════════════════════════════════╣ ║ CLIENT TYPE ║ ║ Confidential — has client_secret (stored server-side only) ║ ║ No PKCE required (client_secret authenticates /token call) ║ ╠══════════════════════════════════════════════════════════════════════╣ ║ TOKEN STORAGE (server-side only — browser never sees tokens) ║ ║ access_token → server-side session store (Redis/DB) ║ ║ refresh_token → server-side session store ║ ║ id_token → server-side session store (validated then stored) ║ ║ Browser holds → HttpOnly session cookie (session_id only) ║ ╠══════════════════════════════════════════════════════════════════════╣ ║ TOKEN LIFETIMES ║ ║ access_token 15 min (server auto-refreshes transparently) ║ ║ id_token 5 min (validate nonce on receipt, then store) ║ ║ refresh_token 24 hrs (rotated on each use) ║ ║ auth code 60 sec (single use) ║ ║ web session 8 hrs (inactivity timeout) ║ ╠══════════════════════════════════════════════════════════════════════╣ ║ CRITICAL VALIDATIONS ║ ║ /callback: state == stored state (CSRF check) ║ ║ id_token: nonce == stored nonce (replay check) ║ ║ id_token: aud == client_id (audience check) ║ ║ JWT: iss == SSO issuer URL ║ ║ JWT: aud includes resource server URL ║ ║ JWT: exp > now() ║ ║ JWT: scope includes required API scope ║ ╠══════════════════════════════════════════════════════════════════════╣ ║ FLOW TYPES ║ ║ Login: /authorize → login → code → POST /token (s2s) ║ ║ Refresh: POST /token (grant=refresh_token, s2s, transparent) ║ ║ Logout: DELETE session → SSO /logout → backchannel notify others ║ ╠══════════════════════════════════════════════════════════════════════╣ ║ ENDPOINTS (ServerSSO) ║ ║ /authorize → get authorization code ║ ║ /token → exchange code / refresh (s2s only) ║ ║ /.well-known/jwks.json → RSA public keys (cached by APIs) ║ ║ /userinfo → get user profile (with access_token) ║ ║ /logout → terminate SSO session ║ ╠══════════════════════════════════════════════════════════════════════╣ ║ KEY SECURITY PROPERTIES ║ ║ XSS cannot steal tokens (stored server-side) ║ ║ CSRF protected by state (verified on callback) ║ ║ Replay protected by nonce (verified in id_token) ║ ║ Code single-use + 60s TTL + client_secret required to exchange ║ ║ JWKS offline validation (no SSO contact per API request) ║ ╚══════════════════════════════════════════════════════════════════════╝
Document maintained by: Platform Security Team Format: DokuWiki Standard: OpenID Connect Core 1.0 · RFC 6749 (OAuth 2.0) · RFC 7519 (JWT) · RFC 7517 (JWK) · OpenID Connect Back-Channel Logout 1.0
