This is an old revision of the document!
Table of Contents
OIDC SSO System — Multi-Application (Browser + WebA + WebB + ServerSSO + JWT + JWKS)
Document Version: 1.0 Last Updated: 2026-06-16 Scope: Single Sign-On across two independent traditional web applications (WebA and WebB),
sharing one ServerSSO. A user authenticates once and gains access to both apps
without re-entering credentials. JWT access tokens are validated via JWKS.
Table of Contents
Architecture Overview
┌─────────────────────────────────────────────────────────────────────────────────┐ │ SYSTEM TOPOLOGY │ │ │ │ User's Browser │ │ ┌──────────────────────────────┐ │ │ │ Tab 1: https://web-a.example│ │ │ │ Tab 2: https://web-b.example│ │ │ │ │ │ │ │ Cookies held by browser: │ │ │ │ ┌─────────────────────────┐ │ │ │ │ │ session_a (web-a domain)│ │ HttpOnly, Secure, SameSite=Lax │ │ │ │ session_b (web-b domain)│ │ HttpOnly, Secure, SameSite=Lax │ │ │ │ sso_session (sso domain)│ │ HttpOnly, Secure, SameSite=None │ │ │ └─────────────────────────┘ │ │ │ └───────────┬──────────────────┘ │ │ │ HTTP + cookies │ │ ┌─────────┴─────────┐ │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────────┐ │ │ │ WebA │ │ WebB │ │ ServerSSO │ │ │ │ Confidential │ │ Confidential │ │ (OIDC Identity Provider) │ │ │ │ OIDC Client │ │ OIDC Client │◀────────▶│ /authorize /token /jwks │ │ │ │ │ │ │ │ /logout /backchannel-logout │ │ │ │ Session Store│ │ Session Store│ │ │ │ │ │ (Redis/DB) │ │ (Redis/DB) │ │ SSO Session Store │ │ │ └──────────────┘ └──────────────┘ └──────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────────────┘
Core SSO Principle:
The browser holds three independent cookies at steady state:
sso_session— on the SSO domain — proves the user authenticated with the IdPsession_a— on WebA's domain — WebA's own session referencing stored JWTsession_b— on WebB's domain — WebB's own session referencing stored JWT
The SSO effect works because both WebA and WebB trust the same ServerSSO. When WebB redirects the user to ServerSSO for login and the SSO session cookie is already present, ServerSSO skips the login page and issues an authorization code immediately.
What Makes This Different
| Aspect | Single App (previous doc) | Multi-App SSO (this doc) |
|---|---|---|
| Number of OIDC clients | 1 (Web App) | 2 (WebA + WebB, independent clients) |
| client_id / client_secret | One pair | Separate pair per application |
| Session stores | One | Separate per app (different domains) |
| id_token audience | web-app-001 | web-a-001 or web-b-001 respectively |
| Browser cookies | session cookie (1) + SSO cookie (1) | session_a + session_b + SSO cookie (3) |
| Cross-app login | N/A | Login to WebA → WebB login is seamless |
| Cross-app logout | Local + SSO | Must propagate to ALL registered clients |
| Token scopes | May overlap | Each app requests only its own scopes |
Components
Browser
| Property | Value |
|---|---|
| Role | User agent; holds cookies; follows redirects |
| Cookies (WebA) | session_a; HttpOnly; Secure; SameSite=Lax |
| Cookies (WebB) | session_b; HttpOnly; Secure; SameSite=Lax |
| Cookies (SSO) | sso_session; HttpOnly; Secure; SameSite=None |
| Sees | HTML pages only — never tokens |
WebA
| Property | Value |
|---|---|
| Role | OIDC Relying Party — Confidential Client |
| client_id | web-a-001 |
| client_secret | Stored in secrets manager (never in browser) |
| Base URL | https://web-a.example.com |
| Redirect URI | https://web-a.example.com/auth/callback |
| Session Store | Redis (keys prefixed weba:) |
| Session Cookie | session_a; domain web-a.example.com |
| Scopes requested | openid profile email api:resourceA |
WebB
| Property | Value |
|---|---|
| Role | OIDC Relying Party — Confidential Client |
| client_id | web-b-001 |
| client_secret | Stored in secrets manager (never in browser) |
| Base URL | https://web-b.example.com |
| Redirect URI | https://web-b.example.com/auth/callback |
| Session Store | Redis (keys prefixed webb:) |
| Session Cookie | session_b; domain web-b.example.com |
| Scopes requested | openid profile email api:resourceB |
ServerSSO
| 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 |
| SSO Session TTL | 8 hours (inactivity); 24 hours (absolute) |
| Registered clients | web-a-001, web-b-001 (and others) |
SSO Session vs Application Session
Understanding the difference between these three layers is essential:
┌─────────────────────────────────────────────────────────────────────┐
│ LAYER 1 — SSO Session (ServerSSO) │
│ │
│ Created when: User successfully authenticates at ServerSSO │
│ Lives at: ServerSSO session store │
│ Identified by: sso_session cookie (domain: sso.example.com) │
│ TTL: 8 hours inactivity / 24 hours absolute │
│ Contains: user identity, authenticated apps list, MFA status │
│ Purpose: Allows ServerSSO to issue codes WITHOUT re-login │
│ │
│ ServerSSO session record: │
│ { │
│ sso_session_id: "SSO-XYZ-789", │
│ sub: "user-uid-456", │
│ email: "alice@example.com", │
│ authenticated_clients: ["web-a-001", "web-b-001"], │
│ auth_time: 1718549100, │
│ last_activity: 1718549100 │
│ } │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ LAYER 2 — WebA Application Session │
│ │
│ Created when: WebA completes OIDC token exchange │
│ Lives at: WebA's Redis session store │
│ Identified by: session_a cookie (domain: web-a.example.com) │
│ TTL: 8 hours │
│ Contains: user info + JWT tokens (access, refresh, id) │
│ Purpose: WebA's own auth state; independent of WebB │
│ │
│ WebA session record: │
│ { │
│ session_id: "SESS-A-abc123", │
│ sub: "user-uid-456", │
│ email: "alice@example.com", │
│ access_token: "eyJhbGci...", │
│ access_token_exp: 1718550000, │
│ refresh_token: "rtA-xyz...", │
│ id_token: "eyJhbGci...", │
│ sso_session_id: "SSO-XYZ-789", │
│ created_at: 1718549100 │
│ } │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ LAYER 3 — WebB Application Session │
│ │
│ Created when: WebB completes its own OIDC token exchange │
│ Lives at: WebB's Redis session store (separate from WebA!) │
│ Identified by: session_b cookie (domain: web-b.example.com) │
│ TTL: 8 hours │
│ Contains: user info + WebB's own JWT tokens │
│ Purpose: WebB's own auth state; independent of WebA │
│ │
│ WebB session record: │
│ { │
│ session_id: "SESS-B-def456", │
│ sub: "user-uid-456", │
│ access_token: "eyJhbGci...(different JWT for WebB scopes)", │
│ access_token_exp: 1718550000, │
│ refresh_token: "rtB-uvw...", │
│ sso_session_id: "SSO-XYZ-789", ← same SSO session │
│ created_at: 1718549200 │
│ } │
└─────────────────────────────────────────────────────────────────────┘
Key insight: WebA and WebB each maintain completely independent sessions and tokens.
They share only thesso_session_idas a reference to the common SSO session.
WebA's tokens cannot be used to call WebB's APIs and vice versa.
JWT & JWKS in Multi-App SSO
Different Tokens for Different Apps
When a user logs into WebA and WebB, ServerSSO issues different JWTs to each:
JWT issued to WebA (via web-a-001 client):
{
"iss": "https://sso.example.com",
"sub": "user-uid-456",
"aud": ["https://resource-a.example.com"], ← WebA's resource server audience
"exp": 1718550000,
"iat": 1718549100,
"jti": "jwt-id-aaa-111",
"email": "alice@example.com",
"scope": "openid profile email api:resourceA", ← WebA's scopes only
"sid": "SSO-XYZ-789"
}
JWT issued to WebB (via web-b-001 client):
{
"iss": "https://sso.example.com",
"sub": "user-uid-456",
"aud": ["https://resource-b.example.com"], ← WebB's resource server audience
"exp": 1718550000,
"iat": 1718549200,
"jti": "jwt-id-bbb-222",
"email": "alice@example.com",
"scope": "openid profile email api:resourceB", ← WebB's scopes only
"sid": "SSO-XYZ-789" ← same SSO session reference
}
Both JWTs are signed with the same RSA private key on ServerSSO and verified against
the same JWKS endpoint. The separation is in aud and scope claims.
JWKS Shared by All Clients
// GET https://sso.example.com/.well-known/jwks.json
// Used by WebA, WebB, and any resource servers to verify JWTs
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "key-2024-01",
"alg": "RS256",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWh...",
"e": "AQAB"
}
]
}
id_token Audience Scoping
id_token for WebA login: "aud": "web-a-001" ← WebA validates this; WebB must NOT accept it id_token for WebB login: "aud": "web-b-001" ← WebB validates this; WebA must NOT accept it This prevents id_token from one app being replayed at another app.
Scenario 1: Unauthenticated User — Visits WebA then WebB
Overview
Alice has no session anywhere. She opens her browser, goes to WebA, logs in, then navigates to WebB. She should only need to enter credentials once.
This scenario has two parts:
- Part A — Alice authenticates via WebA (full login)
- Part B — Alice visits WebB (seamless SSO — no login required)
PART A — First Login via WebA
Step A1 — Browser Requests Protected Page on WebA
Browser → WebA: GET https://web-a.example.com/dashboard Cookie: (none)
Step A2 — WebA Detects No Session
# WebA auth middleware def require_auth_weba(request): session_id = request.COOKIES.get('session_a') session = redis.get(f"weba:{session_id}") if session_id else None if not session: # Generate CSRF state and id_token nonce state = generate_secure_random(32) nonce = generate_secure_random(32) # Store temporarily (5 min TTL) for callback validation redis.setex(f"preauth:weba:{state}", 300, json.dumps({ 'nonce': nonce, 'redirect_to': request.path # remember original URL })) return redirect_to_sso_authorize( client_id = 'web-a-001', redirect_uri = 'https://web-a.example.com/auth/callback', scope = 'openid profile email api:resourceA', state = state, nonce = nonce )
Step A3 — WebA Redirects Browser to ServerSSO /authorize
WebA → Browser:
HTTP 302
Location: https://sso.example.com/authorize
?response_type=code
&client_id=web-a-001
&redirect_uri=https://web-a.example.com/auth/callback
&scope=openid%20profile%20email%20api%3AresourceA
&state=state-weba-f3a8b2c1
&nonce=nonce-weba-9d4e1c7a
Step A4 — Browser Follows Redirect to ServerSSO
Browser → ServerSSO: GET https://sso.example.com/authorize?client_id=web-a-001&... Cookie: (no sso_session cookie)
ServerSSO: no SSO session found → present login page.
Step A5 — ServerSSO Presents Login Page
ServerSSO → Browser:
HTTP 200 Content-Type: text/html
<form method="POST" action="https://sso.example.com/login">
<input name="username" />
<input name="password" type="password" />
<button>Sign In</button>
</form>
Step A6 — Alice Submits Credentials
Browser → ServerSSO: POST https://sso.example.com/login username=alice%40example.com&password=secret123
ServerSSO validates credentials. On success:
- Creates SSO session
SSO-XYZ-789 - Records
authenticated_clients: [](no apps yet) - Generates authorization code
CODE-A-111bound toweb-a-001
Step A7 — ServerSSO Sets SSO Cookie and Redirects with Code
ServerSSO → Browser:
HTTP 302
Set-Cookie: sso_session=SSO-XYZ-789; Domain=sso.example.com;
HttpOnly; Secure; SameSite=None; Max-Age=86400
Location: https://web-a.example.com/auth/callback
?code=CODE-A-111
&state=state-weba-f3a8b2c1
The SSO session cookie is now set on the SSO domain.
The browser will automatically send it on all future requests tosso.example.com.
Step A8 — Browser Follows Redirect to WebA /callback
Browser → WebA:
GET https://web-a.example.com/auth/callback
?code=CODE-A-111
&state=state-weba-f3a8b2c1
Cookie: (none — WebA has no session cookie yet)
Step A9 — WebA Validates state and Exchanges Code
def callback_weba(request): code = request.GET['code'] state = request.GET['state'] # Retrieve stored pre-auth data preauth = redis.get(f"preauth:weba:{state}") if not preauth: raise SecurityError("Unknown or expired state") preauth_data = json.loads(preauth) redis.delete(f"preauth:weba:{state}") # one-time use # Exchange code for tokens — SERVER-TO-SERVER call tokens = sso_token_exchange( code = code, client_id = 'web-a-001', client_secret = WEBA_CLIENT_SECRET, redirect_uri = 'https://web-a.example.com/auth/callback' ) # Validate id_token id_claims = validate_id_token( token = tokens['id_token'], audience = 'web-a-001', nonce = preauth_data['nonce'] ) # Create WebA session session_id = create_session_weba(tokens, id_claims) response = redirect(preauth_data['redirect_to']) response.set_cookie('session_a', session_id, httponly=True, secure=True, samesite='Lax', max_age=28800) return response
Step A10 — WebA Exchanges Code at ServerSSO (Server-to-Server)
WebA Backend → ServerSSO: POST https://sso.example.com/token Authorization: Basic d2ViLWEtMDAxOlNFQ1JFVC1B ← base64(web-a-001:SECRET-A) Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=CODE-A-111 &redirect_uri=https://web-a.example.com/auth/callback
ServerSSO verifies:
client_id+client_secret✅codevalid, not expired, not reused ✅redirect_urimatches exactly ✅
Step A11 — ServerSSO Returns Tokens to WebA
ServerSSO → WebA Backend:
HTTP 200
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...(JWT-A)",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "rtA-xyz-refresh-token",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",
"scope": "openid profile email api:resourceA"
}
ServerSSO also updates its SSO session:
SSO session "SSO-XYZ-789": authenticated_clients: ["web-a-001"] ← WebA added
Step A12 — WebA Stores Session and Sets Cookie
WebA stores in Redis:
Key: "weba:SESS-A-abc123"
Value: {
sub: "user-uid-456",
email: "alice@example.com",
access_token: "JWT-A",
access_token_exp: now + 900,
refresh_token: "rtA-xyz",
sso_session_id: "SSO-XYZ-789",
created_at: now
}
WebA → Browser:
HTTP 302
Set-Cookie: session_a=SESS-A-abc123; Domain=web-a.example.com;
HttpOnly; Secure; SameSite=Lax; Max-Age=28800
Location: https://web-a.example.com/dashboard
Step A13 — Browser Loads WebA Dashboard
Browser → WebA: GET https://web-a.example.com/dashboard Cookie: session_a=SESS-A-abc123 WebA → Browser: HTTP 200 (HTML dashboard page)
WebA may call its own backend resources using the stored JWT-A. The browser only
receives rendered HTML.
State after Part A:
Browser cookies: sso_session=SSO-XYZ-789 (domain: sso.example.com) session_a=SESS-A-abc123 (domain: web-a.example.com) [NO session_b yet] SSO session "SSO-XYZ-789": authenticated_clients: ["web-a-001"] WebA session: exists with JWT-A WebB session: does NOT exist
PART B — Seamless SSO Login to WebB
Alice opens a new tab and navigates to WebB.
Step B1 — Browser Requests Protected Page on WebB
Browser → WebB: GET https://web-b.example.com/reports Cookie: (no session_b cookie — WebB has no session for Alice)
Note: session_a is NOT sent here — it is scoped to web-a.example.com only.
Step B2 — WebB Detects No Session
# WebB auth middleware — identical pattern to WebA def require_auth_webb(request): session_id = request.COOKIES.get('session_b') session = redis.get(f"webb:{session_id}") if session_id else None if not session: state = generate_secure_random(32) nonce = generate_secure_random(32) redis.setex(f"preauth:webb:{state}", 300, json.dumps({ 'nonce': nonce, 'redirect_to': request.path })) return redirect_to_sso_authorize( client_id = 'web-b-001', redirect_uri = 'https://web-b.example.com/auth/callback', scope = 'openid profile email api:resourceB', state = state, nonce = nonce )
Step B3 — WebB Redirects Browser to ServerSSO /authorize
WebB → Browser:
HTTP 302
Location: https://sso.example.com/authorize
?response_type=code
&client_id=web-b-001
&redirect_uri=https://web-b.example.com/auth/callback
&scope=openid%20profile%20email%20api%3AresourceB
&state=state-webb-c7d5e2f1
&nonce=nonce-webb-4b8a3d6e
Step B4 — Browser Follows Redirect to ServerSSO (WITH SSO Cookie)
Browser → ServerSSO: GET https://sso.example.com/authorize?client_id=web-b-001&... Cookie: sso_session=SSO-XYZ-789 ← SSO cookie from Step A7!
This is the SSO magic moment.
Step B5 — ServerSSO Finds Active SSO Session — SKIPS Login Page
# ServerSSO /authorize handler def handle_authorize(request): sso_session_id = request.COOKIES.get('sso_session') sso_session = sso_store.get(sso_session_id) if sso_session and not is_expired(sso_session): # ✅ Valid SSO session found! # No login page needed — user already authenticated # Check if re-authentication required (max_age, prompt, etc.) # For normal flow: skip login entirely # Generate new authorization code for web-b-001 code = generate_code( client_id = 'web-b-001', sso_session = sso_session, scope = request.params['scope'], nonce = request.params['nonce'] ) # Update SSO session sso_session['authenticated_clients'].append('web-b-001') sso_store.update(sso_session_id, sso_session) # Redirect immediately — no login page return redirect(f"{request.params['redirect_uri']}?code={code}&state={request.params['state']}") else: # No SSO session → show login page return show_login_page(request)
ServerSSO → Browser:
HTTP 302
Location: https://web-b.example.com/auth/callback
?code=CODE-B-222
&state=state-webb-c7d5e2f1
(NO login page was shown — completely transparent to Alice)
Step B6 — Browser Follows Redirect to WebB /callback
Browser → WebB:
GET https://web-b.example.com/auth/callback
?code=CODE-B-222
&state=state-webb-c7d5e2f1
Cookie: (no session_b — but that's fine, we have the code)
Step B7 — WebB Validates state and Exchanges Code
def callback_webb(request): code = request.GET['code'] state = request.GET['state'] preauth = redis.get(f"preauth:webb:{state}") if not preauth: raise SecurityError("Unknown or expired state") preauth_data = json.loads(preauth) redis.delete(f"preauth:webb:{state}") tokens = sso_token_exchange( code = code, client_id = 'web-b-001', client_secret = WEBB_CLIENT_SECRET, ← WebB's own secret redirect_uri = 'https://web-b.example.com/auth/callback' ) id_claims = validate_id_token( token = tokens['id_token'], audience = 'web-b-001', ← WebB validates its own id_token nonce = preauth_data['nonce'] ) session_id = create_session_webb(tokens, id_claims) response = redirect(preauth_data['redirect_to']) response.set_cookie('session_b', session_id, httponly=True, secure=True, samesite='Lax', max_age=28800) return response
Step B8 — WebB Exchanges Code at ServerSSO (Server-to-Server)
WebB Backend → ServerSSO: POST https://sso.example.com/token Authorization: Basic d2ViLWItMDAxOlNFQ1JFVC1C ← base64(web-b-001:SECRET-B) grant_type=authorization_code &code=CODE-B-222 &redirect_uri=https://web-b.example.com/auth/callback
Step B9 — ServerSSO Returns WebB-Specific Tokens
ServerSSO → WebB Backend:
HTTP 200
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...(JWT-B)",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "rtB-uvw-refresh-token",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",
"scope": "openid profile email api:resourceB"
}
Note: JWT-B has aud: [“https://resource-b.example.com”] and
scope: “api:resourceB” — completely different from JWT-A.
Step B10 — WebB Creates Session, Sets Cookie, Redirects
WebB stores in Redis:
Key: "webb:SESS-B-def456"
Value: {
sub: "user-uid-456",
email: "alice@example.com",
access_token: "JWT-B",
access_token_exp: now + 900,
refresh_token: "rtB-uvw",
sso_session_id: "SSO-XYZ-789", ← same SSO session
created_at: now
}
WebB → Browser:
HTTP 302
Set-Cookie: session_b=SESS-B-def456; Domain=web-b.example.com;
HttpOnly; Secure; SameSite=Lax; Max-Age=28800
Location: https://web-b.example.com/reports
Step B11 — Browser Loads WebB Reports Page
Browser → WebB: GET https://web-b.example.com/reports Cookie: session_b=SESS-B-def456 WebB → Browser: HTTP 200 (HTML reports page)
Alice is now logged into WebB without ever having entered her credentials a second time.
Final state after Scenario 1:
Browser cookies: sso_session=SSO-XYZ-789 (domain: sso.example.com) session_a=SESS-A-abc123 (domain: web-a.example.com) session_b=SESS-B-def456 (domain: web-b.example.com) SSO session "SSO-XYZ-789": authenticated_clients: ["web-a-001", "web-b-001"] WebA session SESS-A: exists, JWT-A (aud=resource-a, scope=api:resourceA) WebB session SESS-B: exists, JWT-B (aud=resource-b, scope=api:resourceB)
Scenario 1 — Complete Flow Summary
Browser WebA WebB ServerSSO
│ │ │ │
│ PART A — First login (via WebA) │
│─ GET /dash ─▶│ │ │
│ │ no session_a │ │
│◀─ 302 ──────│ │ │
│─ GET /authorize ───────────────────────────▶│
│ │ │ no sso_session │
│◀─ login page ──────────────────────────────│
│─ POST /login ──────────────────────────────▶│
│◀─ sso_session cookie + 302 /callback?code ─│
│─ GET /callback?code=CODE-A ─▶ │
│ │─ POST /token ──────────────────▶│
│ │ (web-a-001 + secret, s2s) │
│ │◀─ {JWT-A, id_token, refresh} ──│
│ │ create SESS-A │
│◀─ 302 + session_a cookie ───│ │
│─ GET /dash ─▶│ │ │
│◀─ 200 HTML ─│ │ │
│ │ │ │
│ PART B — Seamless SSO login (WebB) │
│─ GET /reports ────────────▶│ │
│ │ │ no session_b │
│◀─ 302 ─────────────────────│ │
│─ GET /authorize ───────────────────────────▶│
│ │ │ sso_session ✅ │
│ │ │ SKIP login! │
│◀─ 302 /callback?code=CODE-B ───────────────│
│─ GET /callback?code=CODE-B ────────────────▶│
│ │ │─ POST /token ──▶│
│ │ │ (web-b-001 + secret, s2s)
│ │ │◀─ {JWT-B, ...} ─│
│ │ │ create SESS-B │
│◀─ 302 + session_b cookie ──────────────────│
│─ GET /reports ────────────▶│ │
│◀─ 200 HTML ────────────────│ │
Scenario 2: Authenticated User — Returns to WebA, Opens WebB
Overview
Alice already has valid sessions on both WebA and WebB (from Scenario 1 or a previous visit). This scenario covers what happens when she:
- 2A — Returns to WebA with a valid session and valid token
- 2B — WebA's token has expired — silently refreshed
- 2C — WebA's session has expired — seamless re-auth via SSO session
- 2D — Opens WebB simultaneously — each app manages its session independently
Scenario 2A — Both Sessions Valid
Accessing WebA
Browser → WebA:
GET https://web-a.example.com/dashboard
Cookie: session_a=SESS-A-abc123
WebA middleware:
session = redis.get("weba:SESS-A-abc123")
→ valid, not expired
→ access_token not expired (exp > now + 60s)
→ serve request directly
WebA → Browser:
HTTP 200 (HTML)
Accessing WebB at the Same Time
Browser → WebB:
GET https://web-b.example.com/reports
Cookie: session_b=SESS-B-def456 ← WebB's own cookie
(session_a is NOT sent — different domain)
WebB middleware:
session = redis.get("webb:SESS-B-def456")
→ valid, not expired
→ access_token not expired
→ serve request directly
WebB → Browser:
HTTP 200 (HTML)
Both apps serve their pages without any SSO interaction. The two sessions are completely independent — WebA's session state does not affect WebB and vice versa.
Scenario 2B — WebA Token Expired (Silent Server-Side Refresh)
Step 1 — WebA Detects Expired Token
def get_valid_token_for_weba(session): if session['access_token_exp'] < time.time() + 60: # Token expired or expiring soon — refresh silently new_tokens = refresh_token_weba(session['refresh_token']) if 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'] redis.set(f"weba:{session['id']}", json.dumps(session)) return session['access_token'] else: # Refresh failed → force re-login return None return session['access_token']
Step 2 — WebA Calls ServerSSO /token with Refresh Token (Server-to-Server)
WebA Backend → ServerSSO: POST https://sso.example.com/token Authorization: Basic d2ViLWEtMDAxOlNFQ1JFVC1B grant_type=refresh_token &refresh_token=rtA-xyz-refresh-token
Step 3 — ServerSSO Returns New Tokens
{
"access_token": "eyJhbGci...(new JWT-A)",
"expires_in": 900,
"refresh_token": "rtA-new-rotated-token", ← old token invalidated
"scope": "openid profile email api:resourceA"
}
This is completely invisible to the user and to WebB. WebB's session and tokens are unaffected.
Step 4 — WebA Continues Serving the Request
Browser WebA WebB ServerSSO │ │ │ │ │─ GET /dash ─▶│ │ │ │ │ token expired│ │ │ │─ POST /token (refresh, s2s) ──▶│ │ │◀─ new JWT-A ───────────────────│ │ │ update session │ │◀─ 200 HTML ─│ │ │ │ │ │ │ │─ GET /rpts ────────────────▶│ │ │ │ │ token valid │ │◀─ 200 HTML ─────────────────│ │
Scenario 2C — WebA Session Expired (Re-auth via SSO Session)
Step 1 — WebA Finds No Valid Session
Browser → WebA: GET https://web-a.example.com/dashboard Cookie: session_a=SESS-A-abc123 ← exists but expired in Redis WebA: session lookup returns null (TTL expired) WebA: redirect to SSO /authorize (same as Scenario 1 Step A3)
Step 2 — Browser Arrives at SSO with Valid SSO Session Cookie
Browser → ServerSSO: GET https://sso.example.com/authorize?client_id=web-a-001&... Cookie: sso_session=SSO-XYZ-789 ← SSO session is still valid!
Step 3 — ServerSSO Issues Code Without Login Page
ServerSSO: SSO session valid → skip login → issue code ServerSSO → Browser: HTTP 302 Location: https://web-a.example.com/auth/callback?code=CODE-A-NEW&state=...
Step 4 — WebA Completes Token Exchange, Creates New Session
WebA exchanges code → gets new JWT-A → creates new SESS-A-new → sets new session_a cookie Browser receives new session_a cookie and is redirected to /dashboard User never saw a login prompt.
Browser WebA WebB ServerSSO │ │ │ │ │─ GET /dash ─▶│ │ │ │ │ SESS-A expired │ │◀─ 302 ──────│ │ │ │─ GET /authorize ───────────────────────────▶│ │ │ │ sso_session ✅ │ │◀─ 302 /callback?code ──────────────────────│ │─ GET /callback ─────────────▶ │ │ │─ POST /token ──────────────────▶│ │ │◀─ new tokens ──────────────────│ │ │ create new SESS-A │ │◀─ 302 + new session_a cookie│ │ │─ GET /dash ─▶│ │ │ │◀─ 200 HTML ─│ │ │
WebB is completely unaware of this. Its session and tokens are unaffected.
Scenario 2D — WebB Token Expired (Independent of WebA)
WebB's access token expiry is tracked independently. Refreshing WebB's token has no effect on WebA and requires no interaction from WebA's session.
Browser WebA WebB ServerSSO │ │ │ │ │─ GET /rpts ────────────────▶│ │ │ │ │ token expired │ │ │ │─ POST /token ──▶│ │ │ │ (web-b-001 + rtB, s2s) │ │ │◀─ new JWT-B ────│ │ │ │ update SESS-B │ │◀─ 200 HTML ─────────────────│ │ │ │ │ │ │─ GET /dash ─▶│ │ │ │ │ token valid │ │ │◀─ 200 HTML ─│ │ │
Each application independently manages its own token lifecycle.
Sequence Diagrams
Master Flow: Unauthenticated → WebA → WebB
1. Browser → WebA : GET /page (no session_a)
2. WebA → WebA : generate state-A, nonce-A; store in Redis
3. WebA → Browser : 302 → /authorize?client_id=web-a-001&state=state-A
4. Browser → ServerSSO : GET /authorize (no sso_session)
5. ServerSSO → Browser : 200 login page
6. Browser → ServerSSO : POST /login (credentials)
7. ServerSSO → ServerSSO : validate creds; create SSO session SSO-XYZ
8. ServerSSO → Browser : Set-Cookie: sso_session + 302 → WebA/callback?code=CODE-A
9. Browser → WebA : GET /callback?code=CODE-A&state=state-A
10. WebA → WebA : validate state-A == stored; retrieve nonce-A
11. WebA → ServerSSO : POST /token (code=CODE-A, client_secret-A) [s2s]
12. ServerSSO → ServerSSO : verify client, code, redirect_uri
13. ServerSSO → WebA : { JWT-A, id_token-A, refresh-A }
14. WebA → WebA : validate id_token-A (aud=web-a-001, nonce=nonce-A)
15. WebA → Redis : store SESS-A { JWT-A, refresh-A, sso_sid=SSO-XYZ }
16. WebA → Browser : Set-Cookie: session_a + 302 → /page
17. Browser → WebA : GET /page (Cookie: session_a=SESS-A)
18. WebA → Browser : 200 HTML
── Alice now opens WebB ──
19. Browser → WebB : GET /page (no session_b)
20. WebB → WebB : generate state-B, nonce-B; store in Redis
21. WebB → Browser : 302 → /authorize?client_id=web-b-001&state=state-B
22. Browser → ServerSSO : GET /authorize (Cookie: sso_session=SSO-XYZ ✅)
23. ServerSSO → ServerSSO : SSO session valid → skip login → issue CODE-B
24. ServerSSO → Browser : 302 → WebB/callback?code=CODE-B (no login page!)
25. Browser → WebB : GET /callback?code=CODE-B&state=state-B
26. WebB → WebB : validate state-B == stored; retrieve nonce-B
27. WebB → ServerSSO : POST /token (code=CODE-B, client_secret-B) [s2s]
28. ServerSSO → WebB : { JWT-B, id_token-B, refresh-B }
29. WebB → WebB : validate id_token-B (aud=web-b-001, nonce=nonce-B)
30. WebB → Redis : store SESS-B { JWT-B, refresh-B, sso_sid=SSO-XYZ }
31. WebB → Browser : Set-Cookie: session_b + 302 → /page
32. Browser → WebB : GET /page (Cookie: session_b=SESS-B)
33. WebB → Browser : 200 HTML
API Contracts
ServerSSO Discovery Document
// GET https://sso.example.com/.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"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid","profile","email","api:resourceA","api:resourceB"],
"claims_supported": ["sub","email","name","sid","roles"]
}
POST /token — Authorization Code
WebA request:
POST /token Authorization: Basic base64(web-a-001:SECRET-A) grant_type=authorization_code &code=CODE-A-111 &redirect_uri=https://web-a.example.com/auth/callback
WebB request:
POST /token Authorization: Basic base64(web-b-001:SECRET-B) grant_type=authorization_code &code=CODE-B-222 &redirect_uri=https://web-b.example.com/auth/callback
POST /backchannel_logout
ServerSSO sends this to each registered app on global logout:
POST https://web-a.example.com/auth/backchannel-logout POST https://web-b.example.com/auth/backchannel-logout Content-Type: application/x-www-form-urlencoded logout_token=eyJhbGciOiJSUzI1NiJ9...
Logout token payload:
{
"iss": "https://sso.example.com",
"aud": "web-a-001", ← app-specific (different token sent to each app)
"iat": 1718549100,
"jti": "logout-jti-unique",
"sub": "user-uid-456",
"sid": "SSO-XYZ-789",
"events": { "http://schemas.openid.net/event/backchannel-logout": {} }
}
WebA & WebB Backchannel Logout Handler
# Identical handler pattern for both apps — only Redis prefix differs def backchannel_logout_weba(request): logout_token = request.POST.get('logout_token') # Validate signature via JWKS payload = verify_jwt( token = logout_token, audience = 'web-a-001', issuer = 'https://sso.example.com', jwks_uri = 'https://sso.example.com/.well-known/jwks.json' ) sso_sid = payload['sid'] # Find and delete all WebA sessions linked to this SSO session session_ids = redis.smembers(f"sso_to_sessions:weba:{sso_sid}") for sid in session_ids: redis.delete(f"weba:{sid}") redis.delete(f"sso_to_sessions:weba:{sso_sid}") return HttpResponse(status=200)
Logout Flows
User Logs Out from WebA
When Alice clicks logout in WebA, the logout must propagate to:
- WebA's own session
- The SSO session on ServerSSO
- WebB's session (via backchannel logout)
Step 1: Browser → WebA:
POST https://web-a.example.com/logout
Cookie: session_a=SESS-A-abc123
Step 2: WebA:
a. Retrieve id_token and sso_session_id from SESS-A
b. Delete SESS-A from Redis
c. Revoke refresh_token (optional — call /revoke endpoint)
Step 3: WebA → Browser:
HTTP 302
Set-Cookie: session_a=; Max-Age=0 ← clear cookie
Location: https://sso.example.com/logout
?id_token_hint=eyJhbGci...
&post_logout_redirect_uri=https://web-a.example.com/logged-out
&state=random-state
Step 4: Browser → ServerSSO:
GET /logout?id_token_hint=...
Cookie: sso_session=SSO-XYZ-789
Step 5: ServerSSO:
a. Validates id_token_hint
b. Terminates SSO session SSO-XYZ-789
c. Clears sso_session cookie
d. Looks up authenticated_clients: ["web-a-001", "web-b-001"]
e. Sends backchannel logout to each registered client
Step 6: ServerSSO → WebA (backchannel):
POST https://web-a.example.com/auth/backchannel-logout
(logout_token with aud=web-a-001, sid=SSO-XYZ-789)
WebA: already deleted — ack 200
Step 7: ServerSSO → WebB (backchannel):
POST https://web-b.example.com/auth/backchannel-logout
(logout_token with aud=web-b-001, sid=SSO-XYZ-789)
WebB: delete SESS-B from Redis ← Alice is now logged out of WebB too!
Step 8: ServerSSO → Browser:
HTTP 302
Set-Cookie: sso_session=; Max-Age=0 ← SSO cookie cleared
Location: https://web-a.example.com/logged-out
Step 9: Browser → WebA:
GET https://web-a.example.com/logged-out
(session_a already cleared)
Step 10: WebA → Browser:
HTTP 200 "You have been logged out"
Result: Alice is logged out of WebA, WebB, and the SSO server in a single logout action.
Browser WebA WebB ServerSSO │ │ │ │ │─ POST /logout▶│ │ │ │ │ delete SESS-A│ │ │◀─ 302 /sso/logout ──────────────────────────│ │─ GET /sso/logout ──────────────────────────▶│ │ │ │ terminate SSO │ │ │ │◀─ POST backchannel-logout (web-b-001) │ │ │ delete SESS-B │ │ │◀─ POST backchannel-logout (web-a-001) │ │ (already gone — 200 ack) │ │◀─ 302 /logged-out ─────────────────────────│ │─ GET /logged-out ───▶│ │ │ │◀─ 200 HTML ─│ │ │
Single-App Logout (WebA Only — Without Global SSO Logout)
If WebA wants to log out locally without terminating the SSO session:
1. Delete SESS-A from Redis
2. Clear session_a cookie
3. Redirect to local "logged out from WebA" page
4. Do NOT call SSO /logout endpoint
Result: Alice is logged out of WebA only.
SSO session and WebB session remain active.
If Alice returns to WebA, seamless re-auth via SSO session will occur.
This is sometimes called “local logout” vs “federated logout.”
Security Considerations
Multi-App Specific Security Rules
| Rule | Reason |
|---|---|
| Each app has its own client_secret | Compromise of one app does not expose others |
| id_token audience isolation | WebA's id_token rejected by WebB (aud mismatch) |
| JWT audience isolation | JWT-A usable only at resource-a; JWT-B only at resource-b |
| Separate session stores | WebA cannot read WebB sessions |
| state scoped per app | state generated by WebA is meaningless to WebB |
| Backchannel logout required | Global logout must propagate to all registered clients |
| sso_session_id stored in each session | Enables backchannel logout to find and delete sessions |
Cookie Isolation Between Apps
Domain scoping prevents cross-app cookie access: session_a: Domain=web-a.example.com → sent ONLY to web-a.example.com session_b: Domain=web-b.example.com → sent ONLY to web-b.example.com sso_session: Domain=sso.example.com → sent ONLY to sso.example.com Even if WebA is compromised, it cannot read session_b. Even if WebB is compromised, it cannot read session_a.
Security Checklist
| # | Control | Description |
|---|---|---|
| 1 | Separate client credentials | Unique client_id + client_secret per application |
| 2 | state validation per app | Each app generates and validates its own state |
| 3 | nonce validation per app | Each app generates and validates its own nonce |
| 4 | id_token audience check | Verify aud == own client_id, not any other app's |
| 5 | Cookie domain scoping | session cookies strictly scoped to their app domain |
| 6 | Separate session stores | No shared session data between WebA and WebB |
| 7 | Backchannel logout implemented | Global logout must propagate across all apps |
| 8 | sso_session_id in app sessions | Required to correlate for backchannel logout |
| 9 | JWKS shared but keys validated | Both apps use same JWKS; aud claim separates token scope |
| 10 | Refresh token per app | WebA's refresh_token cannot be used by WebB |
| 11 | HTTPS on all domains | TLS 1.2+ on sso, web-a, and web-b |
| 12 | SameSite=None on SSO cookie | Required for cross-origin redirect flows |
Attack Mitigations
| Attack | Mitigation |
|---|---|
| JWT-A used against WebB's API | aud claim mismatch → rejected by WebB's resource server |
| id_token from WebA replayed at WebB | aud=web-a-001 → WebB rejects (expects web-b-001) |
| Stolen session_a used at WebB | Domain-scoped cookie; WebB only reads session_b |
| CSRF on WebA callback | state validated; attacker cannot predict state-A |
| Logout only from one app | Backchannel logout propagates globally on SSO logout |
| Partial logout (SSO active) | Implement max_age or auth_time checks if stricter needed |
| SSO session hijack | SameSite=None mitigated by HttpOnly + HTTPS + short TTL |
Configuration Reference
ServerSSO Client Registrations
clients: - client_id: "web-a-001" client_secret: "${WEBA_CLIENT_SECRET}" client_type: confidential display_name: "Web Application A" redirect_uris: - "https://web-a.example.com/auth/callback" post_logout_redirect_uris: - "https://web-a.example.com/logged-out" backchannel_logout_uri: "https://web-a.example.com/auth/backchannel-logout" backchannel_logout_session_required: true allowed_scopes: [openid, profile, email, api:resourceA] token_endpoint_auth_method: client_secret_basic access_token_ttl: 900 refresh_token_ttl: 86400 - client_id: "web-b-001" client_secret: "${WEBB_CLIENT_SECRET}" client_type: confidential display_name: "Web Application B" redirect_uris: - "https://web-b.example.com/auth/callback" post_logout_redirect_uris: - "https://web-b.example.com/logged-out" backchannel_logout_uri: "https://web-b.example.com/auth/backchannel-logout" backchannel_logout_session_required: true allowed_scopes: [openid, profile, email, api:resourceB] token_endpoint_auth_method: client_secret_basic access_token_ttl: 900 refresh_token_ttl: 86400
WebA Configuration
oidc: authority: "https://sso.example.com" client_id: "web-a-001" client_secret: "${WEBA_CLIENT_SECRET}" redirect_uri: "https://web-a.example.com/auth/callback" scopes: [openid, profile, email, api:resourceA] token_endpoint_auth_method: client_secret_basic session: store: redis redis_url: "redis://redis-weba:6379/0" key_prefix: "weba:" ttl_seconds: 28800 cookie_name: session_a cookie_domain: web-a.example.com cookie_secure: true cookie_httponly: true cookie_samesite: Lax
WebB Configuration
oidc: authority: "https://sso.example.com" client_id: "web-b-001" client_secret: "${WEBB_CLIENT_SECRET}" redirect_uri: "https://web-b.example.com/auth/callback" scopes: [openid, profile, email, api:resourceB] token_endpoint_auth_method: client_secret_basic session: store: redis redis_url: "redis://redis-webb:6379/0" key_prefix: "webb:" ttl_seconds: 28800 cookie_name: session_b cookie_domain: web-b.example.com cookie_secure: true cookie_httponly: true cookie_samesite: Lax
JWT Validation Configuration (Shared Pattern)
# WebA validates JWT-A issued to resource-a jwt_validation_weba: issuer: "https://sso.example.com" audience: "https://resource-a.example.com" algorithms: [RS256] jwks_uri: "https://sso.example.com/.well-known/jwks.json" jwks_cache_ttl: 3600 required_scope: "api:resourceA" clock_skew: 30 # WebB validates JWT-B issued to resource-b jwt_validation_webb: issuer: "https://sso.example.com" audience: "https://resource-b.example.com" algorithms: [RS256] jwks_uri: "https://sso.example.com/.well-known/jwks.json" jwks_cache_ttl: 3600 required_scope: "api:resourceB" clock_skew: 30
Quick Reference Card
╔═════════════════════════════════════════════════════════════════════════╗ ║ OIDC MULTI-APP SSO — QUICK REFERENCE ║ ╠═════════════════════════════════════════════════════════════════════════╣ ║ THREE-LAYER COOKIE MODEL ║ ║ sso_session → sso.example.com (SameSite=None, HttpOnly, Secure) ║ ║ session_a → web-a.example.com (SameSite=Lax, HttpOnly, Secure) ║ ║ session_b → web-b.example.com (SameSite=Lax, HttpOnly, Secure) ║ ║ Browser never holds tokens — only session IDs ║ ╠═════════════════════════════════════════════════════════════════════════╣ ║ SSO EFFECT — HOW IT WORKS ║ ║ 1. WebA redirects → /authorize (no sso_session) → LOGIN PAGE ║ ║ 2. User logs in → sso_session cookie set on sso.example.com ║ ║ 3. WebB redirects → /authorize (WITH sso_session) → NO LOGIN PAGE ║ ║ 4. ServerSSO issues code immediately, redirects to WebB ║ ╠═════════════════════════════════════════════════════════════════════════╣ ║ TOKEN ISOLATION ║ ║ JWT-A: aud=resource-a, scope=api:resourceA → usable only at A ║ ║ JWT-B: aud=resource-b, scope=api:resourceB → usable only at B ║ ║ id_token-A: aud=web-a-001 → WebA accepts; WebB rejects ║ ║ id_token-B: aud=web-b-001 → WebB accepts; WebA rejects ║ ╠═════════════════════════════════════════════════════════════════════════╣ ║ CLIENT CREDENTIALS (CONFIDENTIAL) ║ ║ WebA: client_id=web-a-001, client_secret=SECRET-A (separate) ║ ║ WebB: client_id=web-b-001, client_secret=SECRET-B (separate) ║ ║ Stored server-side only — never in browser ║ ╠═════════════════════════════════════════════════════════════════════════╣ ║ SESSION INDEPENDENCE ║ ║ WebA token refresh → no effect on WebB session ║ ║ WebA session expiry → no effect on WebB session ║ ║ Each app tracks its own access_token_exp and refresh_token ║ ╠═════════════════════════════════════════════════════════════════════════╣ ║ GLOBAL LOGOUT CHAIN ║ ║ User logs out WebA → WebA calls SSO /logout → SSO terminates session ║ ║ → SSO sends backchannel logout to WebA + WebB ║ ║ → Both app sessions deleted → User fully logged out everywhere ║ ╠═════════════════════════════════════════════════════════════════════════╣ ║ TOKEN LIFETIMES ║ ║ access_token 15 min (per app, independently refreshed) ║ ║ id_token 5 min (validate on receipt; audience-scoped) ║ ║ refresh_token 24 hrs (per app, rotated on use) ║ ║ auth code 60 sec (single use, per app) ║ ║ web session 8 hrs (per app, independent TTL) ║ ║ sso session 8 hrs inactivity / 24 hrs absolute ║ ╚═════════════════════════════════════════════════════════════════════════╝
Document maintained by: Platform Security Team Format: DokuWiki Standard: OpenID Connect Core 1.0 · OpenID Connect Back-Channel Logout 1.0 · RFC 6749 · RFC 7519 · RFC 7517
