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
This document describes a modern, standards-compliant Single Sign-On (SSO) system using:
┌─────────────────────────────────────────────────────────────────┐ │ SYSTEM TOPOLOGY │ │ │ │ ┌──────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ SPA │────▶│ ServerSSO │ │ JWKS Endpoint │ │ │ │(Browser) │ │ (OIDC IdP) │────▶│ /.well-known/ │ │ │ └────┬─────┘ └──────────────┘ │ openid-config │ │ │ │ └──────────────────┘ │ │ │ Bearer JWT │ │ ├──────────────────────────────▶ ┌──────────────────┐ │ │ │ │ ServerA │ │ │ │ │ (Resource API) │ │ │ │ └──────────────────┘ │ │ │ │ │ └──────────────────────────────▶ ┌──────────────────┐ │ │ │ ServerB │ │ │ │ (Resource API) │ │ │ └──────────────────┘ │ └─────────────────────────────────────────────────────────────────┘
| 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 |
| 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 |
| 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 |
| 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 |
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 | 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 |
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"]
}
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)
}
]
}
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.
Alice opens the SPA in her browser for the first time (no session, no tokens). She wants to access a protected resource on ServerA.
// 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.
// 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)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 |
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.
ServerSSO ──▶ Browser:
HTTP 302
Location: https://app.example.com/callback
?code=SplxlOBeZQQYbYS6WxSbIA
&state=random-csrf-token-abc123
What happens:
code ↔ code_challenge ↔ requested scopes// 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
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:
SHA256(code_verifier) == stored code_challenge ← PKCE checkredirect_uri matches registered valueHTTP 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
GET https://api-a.example.com/api/data Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...
// 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.
HTTP 200 OK
{
"data": "Protected content from ServerA",
"user": "alice@example.com"
}
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 } ──────────────────────────────────────│
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:
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.
GET https://api-a.example.com/api/data Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...
ServerA validates JWT offline via JWKS (same as Scenario 1 Step 10).
The same access_token works for ServerB because:
aud claim includes https://api-b.example.comscope includes api:serverBGET https://api-b.example.com/api/data Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...
ServerB independently validates the JWT using its own JWKS cache.
const token = getValidToken(); if (!token) { // Token expired → silent refresh using refresh_token await silentRefresh(); }
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.
ServerSSO checks:
If the SSO session was terminated (logout, password change), refresh fails with invalid_grant.
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.
window.__auth = { accessToken: newTokenResponse.access_token, expiresAt: Date.now() + (newTokenResponse.expires_in * 1000) }; // Update stored refresh_token sessionStorage.setItem('refresh_token', newTokenResponse.refresh_token);
With the new access_token, the SPA calls ServerA and ServerB as normal.
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.
// 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);
GET https://sso.example.com/authorize ?response_type=code &client_id=spa-client-001 &prompt=none ← Do not show login UI &...pkce_params...
error=login_required// 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).
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: 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 }
{
"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"]
}
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"
}
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 |
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 |
| 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 |
| # | 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 | 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 |
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
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
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' } };
// 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:
post_logout_redirect_uriNote: 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.
╔═══════════════════════════════════════════════════════════════╗ ║ 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)