Table of Contents
OIDC SSO System — SPA + PKCE + JWT + JWKS
Document Version: 1.0 Last Updated: 2026-06-16 Scope: Single Sign-On architecture with SPA (PKCE), two resource servers, one SSO/IdP server, JWT token validation via JWKS
Table of Contents
Architecture Overview
This document describes a modern, standards-compliant Single Sign-On (SSO) system using:
- OpenID Connect (OIDC) — identity layer on top of OAuth 2.0
- PKCE (Proof Key for Code Exchange) — protects SPAs from authorization code interception
- JWT (JSON Web Token) — compact, self-contained token format
- JWKS (JSON Web Key Set) — public key endpoint for token verification
┌─────────────────────────────────────────────────────────────────┐ │ SYSTEM TOPOLOGY │ │ │ │ ┌──────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ SPA │────▶│ ServerSSO │ │ JWKS Endpoint │ │ │ │(Browser) │ │ (OIDC IdP) │────▶│ /.well-known/ │ │ │ └────┬─────┘ └──────────────┘ │ openid-config │ │ │ │ └──────────────────┘ │ │ │ Bearer JWT │ │ ├──────────────────────────────▶ ┌──────────────────┐ │ │ │ │ ServerA │ │ │ │ │ (Resource API) │ │ │ │ └──────────────────┘ │ │ │ │ │ └──────────────────────────────▶ ┌──────────────────┐ │ │ │ ServerB │ │ │ │ (Resource API) │ │ │ └──────────────────┘ │ └─────────────────────────────────────────────────────────────────┘
Components
SPA (Single Page Application)
| Property | Value |
|---|---|
| Role | OIDC Relying Party (RP) / Client |
| Client Type | Public (no client_secret) |
| Auth Method | PKCE (S256) |
| Token Storage | Memory (access_token), HttpOnly Cookie or sessionStorage (refresh_token) |
| Base URL | https://app.example.com |
| Redirect URI | https://app.example.com/callback |
ServerSSO (Identity Provider / Authorization Server)
| Property | Value |
|---|---|
| Role | OIDC Provider (OP) / Auth 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 |
ServerA (Resource Server)
| Property | Value |
|---|---|
| Role | OAuth 2.0 Resource Server |
| Validates | JWT Bearer token via JWKS |
| Required Scope | api:serverA |
| Base URL | https://api-a.example.com |
ServerB (Resource Server)
| Property | Value |
|---|---|
| Role | OAuth 2.0 Resource Server |
| Validates | JWT Bearer token via JWKS |
| Required Scope | api:serverB |
| Base URL | https://api-b.example.com |
OIDC + PKCE Flow Fundamentals
What is PKCE?
PKCE (RFC 7636) prevents authorization code interception attacks in public clients (SPAs, mobile apps). It works by binding the authorization request to the token exchange using a cryptographic challenge.
Step 1 — Generate code_verifier (random 43-128 char string)
code_verifier = base64url(random_bytes(32))
Step 2 — Derive code_challenge
code_challenge = base64url(SHA256(ASCII(code_verifier)))
Step 3 — Send code_challenge in /authorize request
(keep code_verifier secret in memory)
Step 4 — Send code_verifier in /token exchange
ServerSSO verifies: SHA256(code_verifier) == code_challenge
Token Types
| Token | Lifetime | Purpose | Storage |
|---|---|---|---|
| id_token | 5 min | User identity (OpenID Connect) | Memory only |
| access_token | 15 min | API authorization (Bearer) | Memory only |
| refresh_token | 24 hours | Obtain new access_token | HttpOnly Cookie |
JWT & JWKS Explained
JWT Structure
A JWT has three base64url-encoded parts separated by dots:
header.payload.signature
Example decoded header:
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-2024-01" ← Key ID — used to find the right key in JWKS
}
Example decoded payload:
{
"iss": "https://sso.example.com",
"sub": "user-abc-123",
"aud": ["https://api-a.example.com", "https://api-b.example.com"],
"exp": 1718550000,
"iat": 1718549100,
"nbf": 1718549100,
"jti": "unique-token-id-xyz",
"email": "alice@example.com",
"scope": "openid profile email api:serverA api:serverB",
"roles": ["user", "editor"]
}
JWKS Endpoint
Resource servers call the JWKS endpoint to get public keys for JWT verification. They cache the response (typically 1 hour) and only re-fetch when an unknown kid appears.
// GET https://sso.example.com/.well-known/jwks.json
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "key-2024-01",
"alg": "RS256",
"n": "0vx7agoebGcQSuuPiLJX...", ← RSA modulus (public)
"e": "AQAB" ← RSA exponent (public)
}
]
}
JWT Validation Steps (Resource Servers)
Each resource server independently validates every incoming JWT:
1. Parse JWT header → extract kid 2. Fetch JWKS (from cache or live) → find key matching kid 3. Verify signature using RSA public key 4. Check iss == "https://sso.example.com" 5. Check aud includes this server's audience 6. Check exp > now() 7. Check nbf <= now() 8. Check iat is not in the future 9. Check required scope present (e.g. "api:serverA") 10. Extract sub, roles, email for authorization logic
Important: Resource servers NEVER contact the SSO server during request processing.
They validate JWTs offline using cached JWKS public keys.
This makes the system highly scalable and resilient.
Scenario 1: Unauthenticated User
Overview
Alice opens the SPA in her browser for the first time (no session, no tokens). She wants to access a protected resource on ServerA.
Step-by-Step Flow
Step 1 — SPA Detects No Session
// SPA startup check function checkSession() { const accessToken = sessionStorage.getItem('access_token'); // or in-memory if (!accessToken || isExpired(accessToken)) { initiateLogin(); } }
What happens: The SPA checks memory/storage for a valid access_token. None found → triggers login.
Step 2 — PKCE: Generate code_verifier & code_challenge
// Generate cryptographically random verifier function generateCodeVerifier() { const array = new Uint8Array(32); crypto.getRandomValues(array); return base64UrlEncode(array); } // Derive challenge using SHA-256 async function generateCodeChallenge(verifier) { const encoder = new TextEncoder(); const data = encoder.encode(verifier); const hash = await crypto.subtle.digest('SHA-256', data); return base64UrlEncode(new Uint8Array(hash)); } const code_verifier = generateCodeVerifier(); // Store in sessionStorage const code_challenge = await generateCodeChallenge(code_verifier); sessionStorage.setItem('pkce_verifier', code_verifier);
What happens: Two values are created:
code_verifier— kept secret in sessionStoragecode_challenge— will be sent to SSO (SHA256 hash of verifier)
Step 3 — SPA Redirects Browser to ServerSSO /authorize
GET https://sso.example.com/authorize ?response_type=code &client_id=spa-client-001 &redirect_uri=https://app.example.com/callback &scope=openid profile email api:serverA api:serverB &state=random-csrf-token-abc123 &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256
Parameters:
| Parameter | Value | Purpose |
|---|---|---|
| response_type | code | Request an authorization code |
| client_id | spa-client-001 | Identifies the SPA |
| redirect_uri | https://app.example.com/callback | Where to return after login |
| scope | openid profile email api:serverA api:serverB | Requested permissions |
| state | Random string | CSRF protection |
| code_challenge | SHA256(code_verifier) | PKCE binding |
| code_challenge_method | S256 | Hash algorithm |
Step 4 — ServerSSO Authenticates Alice
ServerSSO presents a login page. Alice enters credentials.
Browser ──▶ ServerSSO: GET /authorize (params above)
ServerSSO ──▶ Browser: 302 → /login?session=xyz
Browser ──▶ ServerSSO: POST /login { username, password }
ServerSSO: validates credentials, creates SSO session cookie
If this is the first SSO login or MFA is required, the SSO server handles it transparently.
Step 5 — ServerSSO Returns Authorization Code
ServerSSO ──▶ Browser:
HTTP 302
Location: https://app.example.com/callback
?code=SplxlOBeZQQYbYS6WxSbIA
&state=random-csrf-token-abc123
What happens:
- ServerSSO stores a binding:
code↔code_challenge↔ requested scopes - The code is single-use and expires in 60 seconds
- Browser follows the redirect to the SPA callback URL
Step 6 — SPA Validates state and Extracts Code
// SPA /callback handler const params = new URLSearchParams(window.location.search); const code = params.get('code'); const state = params.get('state'); // CSRF check if (state !== sessionStorage.getItem('oauth_state')) { throw new Error('State mismatch — possible CSRF attack'); } // Retrieve PKCE verifier const code_verifier = sessionStorage.getItem('pkce_verifier'); sessionStorage.removeItem('pkce_verifier'); // clean up
Step 7 — SPA Exchanges Code for Tokens
POST https://sso.example.com/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=SplxlOBeZQQYbYS6WxSbIA &redirect_uri=https://app.example.com/callback &client_id=spa-client-001 &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
ServerSSO verification:
- Checks code is valid and not expired
- Verifies
SHA256(code_verifier) == stored code_challenge← PKCE check - Verifies
redirect_urimatches registered value - Issues tokens
Step 8 — ServerSSO Returns JWT Tokens
HTTP 200 OK
Content-Type: application/json
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "8xLOxBtZp8",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",
"scope": "openid profile email api:serverA api:serverB"
}
SPA stores tokens:
// In-memory (most secure for access_token) window.__auth = { accessToken: tokenResponse.access_token, idToken: tokenResponse.id_token, expiresAt: Date.now() + (tokenResponse.expires_in * 1000) }; // HttpOnly cookie for refresh_token (set by SSO server, or BFF pattern) // If SPA-only: sessionStorage with strict CSP
Step 9 — SPA Calls ServerA with Bearer Token
GET https://api-a.example.com/api/data Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...
Step 10 — ServerA Validates JWT (JWKS)
// ServerA middleware (Node.js example) async function validateJWT(req, res, next) { const token = req.headers.authorization?.replace('Bearer ', ''); // 1. Decode header to get kid const header = JSON.parse(atob(token.split('.')[0])); // 2. Fetch JWKS (cached 1 hour, or refetch on unknown kid) const jwks = await getJWKS('https://sso.example.com/.well-known/jwks.json'); const key = jwks.keys.find(k => k.kid === header.kid); // 3. Verify signature + claims const payload = await verifyJWT(token, key, { issuer: 'https://sso.example.com', audience: 'https://api-a.example.com', algorithms: ['RS256'] }); // 4. Check required scope if (!payload.scope.includes('api:serverA')) { return res.status(403).json({ error: 'insufficient_scope' }); } req.user = payload; next(); }
ServerSSO is NOT contacted during this step. ServerA validates the JWT entirely using the cached JWKS public key.
Step 11 — ServerA Returns Protected Resource
HTTP 200 OK
{
"data": "Protected content from ServerA",
"user": "alice@example.com"
}
Scenario 1 — Complete Flow Summary
SPA ServerSSO ServerA ServerB
│ │ │ │
│─ checkSession ──▶│ │ │
│ (no token) │ │ │
│ │ │ │
│─ generate PKCE ─▶│ │ │
│ │ │ │
│─ GET /authorize ─▶ │ │
│ │ (login page) │ │
│◀─ 302 /login ───│ │ │
│─ POST /login ───▶│ │ │
│◀─ 302 /callback?code= ──────────────│ │
│ │ │ │
│─ POST /token ───▶│ │ │
│ (+ code_verifier) │ │
│◀─ {access_token, id_token, ...} ────│ │
│ │ │ │
│─ GET /api/data ─────────────────────▶│ │
│ Authorization: Bearer <JWT> │ │
│ │ validate JWT │ │
│ │ (JWKS, offline) │ │
│◀─ 200 { data } ─────────────────────│ │
│ │ │ │
│─ GET /api/data ─────────────────────────────────────▶│
│ Authorization: Bearer <JWT> │
│ │ validate JWT (JWKS, offline) │
│◀─ 200 { data } ──────────────────────────────────────│
Scenario 2: Authenticated User
Overview
Alice returns to the SPA. She already has a valid SSO session (SSO session cookie in browser) and a valid access_token in memory. She accesses both ServerA and ServerB.
This scenario has two sub-cases:
- 2A — Access token still valid → used directly
- 2B — Access token expired → refreshed silently using refresh_token
Scenario 2A — Access Token Still Valid
Step 1 — SPA Checks Token in Memory
function getValidToken() { const auth = window.__auth; if (auth && auth.expiresAt > Date.now() + 30000) { // 30s buffer return auth.accessToken; // Still valid → use directly } return null; // Expired → go to 2B }
What happens: Token is still valid. No SSO interaction needed.
Step 2 — SPA Calls ServerA Directly
GET https://api-a.example.com/api/data Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...
ServerA validates JWT offline via JWKS (same as Scenario 1 Step 10).
Step 3 — SPA Calls ServerB with Same Token
The same access_token works for ServerB because:
audclaim includeshttps://api-b.example.comscopeincludesapi:serverB
GET https://api-b.example.com/api/data Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...
ServerB independently validates the JWT using its own JWKS cache.
Scenario 2B — Access Token Expired (Silent Refresh)
Step 1 — SPA Detects Expired Access Token
const token = getValidToken(); if (!token) { // Token expired → silent refresh using refresh_token await silentRefresh(); }
Step 2 — SPA Sends Refresh Token to ServerSSO
POST https://sso.example.com/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token &refresh_token=8xLOxBtZp8 &client_id=spa-client-001
No user interaction required. This is a background HTTP call.
Step 3 — ServerSSO Validates Refresh Token
ServerSSO checks:
- refresh_token is valid and not revoked
- refresh_token is not expired (24h TTL)
- SSO session is still active
If the SSO session was terminated (logout, password change), refresh fails with invalid_grant.
Step 4 — ServerSSO Issues New Tokens
HTTP 200 OK
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...(new)",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "9yMpaBuA3Rx", ← Rotated refresh_token (new one issued)
"scope": "openid profile email api:serverA api:serverB"
}
Refresh token rotation: Every refresh issues a NEW refresh_token and invalidates the old one. This limits the damage if a refresh_token is stolen.
Step 5 — SPA Updates Token in Memory
window.__auth = { accessToken: newTokenResponse.access_token, expiresAt: Date.now() + (newTokenResponse.expires_in * 1000) }; // Update stored refresh_token sessionStorage.setItem('refresh_token', newTokenResponse.refresh_token);
Step 6 — SPA Continues API Calls
With the new access_token, the SPA calls ServerA and ServerB as normal.
Scenario 2B — SSO Session Already Exists (New Tab)
If Alice opens a new browser tab, the SPA has no token in memory (fresh JS context), but the browser has an active SSO session cookie.
Step 1 — SPA Initiates Silent Auth via Hidden iframe
// Attempt silent authorization (prompt=none) const silentAuthUrl = buildAuthUrl({ prompt: 'none', // ← Do NOT show login UI ...pkceParams }); // Load in hidden iframe const iframe = document.createElement('iframe'); iframe.style.display = 'none'; iframe.src = silentAuthUrl; document.body.appendChild(iframe);
Step 2 — ServerSSO Checks SSO Session Cookie
GET https://sso.example.com/authorize ?response_type=code &client_id=spa-client-001 &prompt=none ← Do not show login UI &...pkce_params...
- If SSO session valid → returns code immediately (no login page)
- If SSO session invalid → returns
error=login_required
Step 3 — SPA Receives Code via postMessage
// SPA callback page (loaded in iframe) sends result to parent window.parent.postMessage({ type: 'silent_auth_result', code: params.get('code'), state: params.get('state') }, 'https://app.example.com');
SPA proceeds with token exchange (same as Scenario 1 Steps 7–8).
Scenario 2 — Complete Flow Summary
SPA ServerSSO ServerA ServerB
│ │ │ │
│ [2A: Token valid]│ │ │
│─ GET /api/data ─────────────────────▶│ │
│◀─ 200 { data } ─────────────────────│ │
│ │ │ │
│─ GET /api/data ─────────────────────────────────────▶│
│◀─ 200 { data } ──────────────────────────────────────│
│ │ │ │
│ [2B: Token expired] │ │
│─ POST /token ───▶│ │ │
│ grant=refresh_token │ │
│◀─ new {access_token, refresh_token}─│ │
│ │ │ │
│─ GET /api/data ─────────────────────▶│ │
│◀─ 200 { data } ─────────────────────│ │
│ │ │ │
│ [2C: New tab, SSO session active] │ │
│─ GET /authorize?prompt=none ────────▶│ │
│ (hidden iframe) │ │
│◀─ 302 /callback?code= ──────────────│ │
│─ POST /token ───▶│ │ │
│◀─ {access_token, ...} ──────────────│ │
│─ GET /api/data ─────────────────────▶│ │
│◀─ 200 { data } ─────────────────────│ │
Sequence Diagrams
Full PKCE Authorization Code Flow
Sequence: SPA → SSO → Callback → Token → API
1. SPA → (browser) : generate code_verifier, code_challenge
2. SPA → ServerSSO : GET /authorize?code_challenge=...
3. ServerSSO → (browser) : 302 to /login
4. User → ServerSSO : POST /login (credentials)
5. ServerSSO → ServerSSO : create SSO session, bind code→challenge
6. ServerSSO → (browser) : 302 to /callback?code=...&state=...
7. SPA → SPA : validate state, retrieve code_verifier
8. SPA → ServerSSO : POST /token (code + code_verifier)
9. ServerSSO → ServerSSO : verify SHA256(code_verifier)==code_challenge
10. ServerSSO → SPA : { access_token, id_token, refresh_token }
11. SPA → ServerA : GET /api (Authorization: Bearer JWT)
12. ServerA → ServerSSO : GET /jwks.json (cached, if needed)
13. ServerA → ServerA : verify JWT signature + claims
14. ServerA → SPA : 200 { data }
15. SPA → ServerB : GET /api (same JWT)
16. ServerB → ServerB : verify JWT (own JWKS cache)
17. ServerB → SPA : 200 { data }
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",
"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": ["none"],
"code_challenge_methods_supported": ["S256"]
}
POST /token — Authorization Code
Request:
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code={authorization_code}
&redirect_uri={registered_redirect_uri}
&client_id={client_id}
&code_verifier={pkce_verifier}
Response:
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "...",
"id_token": "...",
"scope": "openid profile email api:serverA api:serverB"
}
POST /token — Refresh Token
Request:
grant_type=refresh_token
&refresh_token={token}
&client_id={client_id}
Error Responses:
| HTTP | error | Meaning |
|---|---|---|
| 400 | invalid_request | Missing required parameter |
| 400 | invalid_grant | Code/refresh_token invalid or expired |
| 400 | invalid_client | Unknown client_id |
| 403 | insufficient_scope | Requested scope not permitted |
ServerA & ServerB Protected Endpoints
Request (all protected routes):
Authorization: Bearer {access_token}
Error Responses:
| HTTP | error | Meaning |
|---|---|---|
| 401 | invalid_token | JWT missing, malformed, or expired |
| 401 | token_expired | JWT exp in the past |
| 403 | insufficient_scope | JWT lacks required scope |
| 403 | invalid_audience | JWT aud doesn't include this server |
Security Considerations
Token Storage
| Location | access_token | refresh_token | Risk |
|---|---|---|---|
| Memory (JS var) | ✅ Recommended | ⚠️ Lost on refresh | Low (XSS can't access) |
| sessionStorage | ⚠️ Acceptable | ⚠️ Acceptable | XSS readable |
| localStorage | ❌ Avoid | ❌ Avoid | XSS readable, persistent |
| HttpOnly Cookie | ⚠️ CSRF risk | ✅ Recommended | CSRF mitigated by SameSite |
Security Checklist
| # | Control | Description |
|---|---|---|
| 1 | PKCE S256 | Always use S256, never plain |
| 2 | state validation | Verify state before code exchange (CSRF protection) |
| 3 | Redirect URI exact match | SSO must reject partial/wildcard URIs |
| 4 | Short-lived access tokens | Max 15 minutes |
| 5 | Refresh token rotation | Issue new refresh_token on every use |
| 6 | Audience validation | Each server checks aud claim |
| 7 | JWKS caching with TTL | Cache JWKS for 1 hour; refetch on unknown kid |
| 8 | HTTPS everywhere | All endpoints use TLS 1.2+ |
| 9 | CSP headers on SPA | Prevent XSS with strict Content-Security-Policy |
| 10 | Refresh token binding | Optionally bind refresh_token to IP/user-agent |
Attack Mitigations
| Attack | Mitigation |
|---|---|
| Authorization Code Interception | PKCE: SHA256 binding between code and verifier |
| CSRF on /authorize | state parameter validated by SPA |
| Token theft (XSS) | access_token in memory; refresh_token HttpOnly cookie |
| Replay attack | jti claim + short expiry; server tracks used jti |
| Key compromise | Rotate RSA keys regularly; JWKS kid-based selection |
| Session fixation | SSO issues new session ID after successful login |
Configuration Reference
ServerSSO Configuration
sso: issuer: "https://sso.example.com" access_token_ttl: 900 # 15 minutes refresh_token_ttl: 86400 # 24 hours id_token_ttl: 300 # 5 minutes authorization_code_ttl: 60 # 60 seconds signing: algorithm: RS256 key_rotation_days: 90 clients: - client_id: "spa-client-001" client_type: public # No client_secret redirect_uris: - "https://app.example.com/callback" allowed_scopes: - openid - profile - email - api:serverA - api:serverB pkce_required: true pkce_method: S256
ServerA & ServerB JWT Validation Configuration
jwt: issuer: "https://sso.example.com" audience: "https://api-a.example.com" # (or api-b for ServerB) algorithms: [RS256] jwks_uri: "https://sso.example.com/.well-known/jwks.json" jwks_cache_ttl: 3600 # 1 hour cache required_scope: "api:serverA" # (or api:serverB) clock_skew_tolerance: 30 # seconds
SPA Environment Configuration
const AUTH_CONFIG = { authority: 'https://sso.example.com', clientId: 'spa-client-001', redirectUri: 'https://app.example.com/callback', scopes: ['openid', 'profile', 'email', 'api:serverA', 'api:serverB'], responseType: 'code', pkce: true, apis: { serverA: 'https://api-a.example.com', serverB: 'https://api-b.example.com' } };
Logout
SPA-initiated Logout
// 1. Clear local tokens window.__auth = null; sessionStorage.clear(); // 2. Redirect to SSO end_session_endpoint const logoutUrl = new URL('https://sso.example.com/logout'); logoutUrl.searchParams.set('id_token_hint', idToken); logoutUrl.searchParams.set('post_logout_redirect_uri', 'https://app.example.com'); window.location.href = logoutUrl.toString();
What ServerSSO does on logout:
- Invalidates SSO session cookie
- Revokes all refresh_tokens for the session
- Notifies registered clients via backchannel logout (optional)
- Redirects back to
post_logout_redirect_uri
Note: Access tokens remain valid until expiry (they are not revoked).
This is by design — JWTs are stateless. For immediate revocation, use
short TTLs (≤15 min) or implement a token introspection/revocation endpoint.
Quick Reference Card
╔═══════════════════════════════════════════════════════════════╗ ║ OIDC PKCE QUICK REFERENCE ║ ╠═══════════════════════════════════════════════════════════════╣ ║ PKCE ║ ║ code_verifier = base64url(random 32 bytes) ║ ║ code_challenge = base64url(SHA256(code_verifier)) ║ ║ method = S256 ║ ╠═══════════════════════════════════════════════════════════════╣ ║ ENDPOINTS ║ ║ /authorize → get authorization code ║ ║ /token → exchange code or refresh token ║ ║ /jwks.json → public keys for JWT verification ║ ║ /userinfo → get user profile (with access_token) ║ ║ /logout → end SSO session ║ ╠═══════════════════════════════════════════════════════════════╣ ║ JWT CLAIMS TO VALIDATE ║ ║ iss = ServerSSO issuer URL ║ ║ aud = this server's URL ║ ║ exp > now() ║ ║ nbf ≤ now() ║ ║ scope includes required API scope ║ ╠═══════════════════════════════════════════════════════════════╣ ║ TOKEN LIFETIMES ║ ║ access_token 15 min (in memory) ║ ║ id_token 5 min (in memory) ║ ║ refresh_token 24 hrs (HttpOnly cookie) ║ ║ auth code 60 sec (single use) ║ ╚═══════════════════════════════════════════════════════════════╝
Document maintained by: Platform Security Team Format: DokuWiki Standard: OpenID Connect Core 1.0, RFC 7636 (PKCE), RFC 7519 (JWT), RFC 7517 (JWK)
