User Tools

Site Tools


security:sso-web

Table of Contents

OIDC SSO System — Multi-Application (Browser + WebA + WebB + ServerSSO + JWT + JWKS)

Document Version: 2.0 Last Updated: 2026-06-16 Changes in v2.0: Added full RSA Key Usage section; annotated every step in both scenarios

                  with which key (private / public) is used and by which component.

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:

  1. sso_session — on the SSO domain — proves the user authenticated with the IdP
  2. session_a — on WebA's domain — WebA's own session referencing stored JWT
  3. session_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)

RSA Key Usage — Private Key vs Public Key

The Asymmetric Key Pair

ServerSSO uses RS256 (RSA + SHA-256), an asymmetric signing algorithm. This means two mathematically linked keys exist with strictly separate roles:

┌─────────────────────────────────────────────────────────────────────────────┐
│                     RSA KEY PAIR — WHO HOLDS WHAT                          │
│                                                                             │
│   ┌───────────────────────────────────────────────────────┐                │
│   │              ServerSSO                                │                │
│   │  ┌─────────────────────────────────────────────────┐  │                │
│   │  │  🔐 PRIVATE KEY  (RSA-2048 or RSA-4096)         │  │                │
│   │  │  kid: "key-2024-01"                             │  │                │
│   │  │  Stored in: HSM / KMS / encrypted key store     │  │                │
│   │  │  NEVER leaves ServerSSO — never shared          │  │                │
│   │  │  Used to: SIGN JWTs (access_token, id_token,    │  │                │
│   │  │                       logout_token)             │  │                │
│   │  └─────────────────────────────────────────────────┘  │                │
│   │  ┌─────────────────────────────────────────────────┐  │                │
│   │  │  🔓 PUBLIC KEY  (exposed via JWKS endpoint)     │  │                │
│   │  │  kid: "key-2024-01"                             │  │                │
│   │  │  Available at: /.well-known/jwks.json           │  │                │
│   │  │  Anyone can fetch it — it is intentionally      │  │                │
│   │  │  public (n + e RSA components only)             │  │                │
│   │  │  Used to: VERIFY JWT signatures                 │  │                │
│   │  └─────────────────────────────────────────────────┘  │                │
│   └───────────────────────────────────────────────────────┘                │
│                                                                             │
│   ┌────────────────────┐        ┌────────────────────┐                     │
│   │       WebA         │        │       WebB         │                     │
│   │  🔓 PUBLIC KEY     │        │  🔓 PUBLIC KEY     │                     │
│   │  (fetched via JWKS │        │  (fetched via JWKS │                     │
│   │   and cached)      │        │   and cached)      │                     │
│   │  Used to: VERIFY   │        │  Used to: VERIFY   │                     │
│   │  id_token sig      │        │  id_token sig      │                     │
│   │  logout_token sig  │        │  logout_token sig  │                     │
│   └────────────────────┘        └────────────────────┘                     │
└─────────────────────────────────────────────────────────────────────────────┘

Fundamental Rule

SIGN with PRIVATE KEY → VERIFY with PUBLIC KEY

Only ServerSSO can create valid JWT signatures (it alone holds the private key).
Anyone with the public key (WebA, WebB, or any other party) can verify those signatures
but can NEVER forge a new one.

Key Usage Master Table

Component Key Type Key ID Operation Token / Object When
ServerSSO 🔐 Private key-2024-01 SIGN access_token (JWT) Every /token response
ServerSSO 🔐 Private key-2024-01 SIGN id_token (JWT) Every /token response
ServerSSO 🔐 Private key-2024-01 SIGN logout_token (JWT) Every backchannel logout call
ServerSSO 🔓 Public key-2024-01 EXPOSE via JWKS n, e (RSA components) GET /.well-known/jwks.json (always)
WebA 🔓 Public key-2024-01 VERIFY signature id_token received Step A11 — after /token exchange
WebA 🔓 Public key-2024-01 VERIFY signature logout_token received Every backchannel logout
WebB 🔓 Public key-2024-01 VERIFY signature id_token received Step B9 — after /token exchange
WebB 🔓 Public key-2024-01 VERIFY signature logout_token received Every backchannel logout
Note on access_token: In this architecture, WebA and WebB store the access_token
server-side and forward it to resource APIs. If WebA/WebB also need to inspect the
access_token's claims (e.g. to check roles before calling an API), they would also
use the public key to verify it. The public key is fetched once and cached per app.

Where the Private Key Lives

ServerSSO key storage options (most secure first):

1. Hardware Security Module (HSM)
   → Key material never exists in software memory
   → Signing operation performed inside HSM hardware
   → Best for production

2. Cloud KMS (AWS KMS, GCP Cloud KMS, Azure Key Vault)
   → Private key stored in managed key store
   → Signing done via API call to KMS
   → Key never exported to disk

3. Encrypted key file (minimum acceptable)
   → PEM file encrypted with passphrase
   → Loaded into process memory at startup
   → Passphrase injected via secrets manager at deploy time
   → Never committed to source control

❌ NEVER:
   → Hardcode in source code
   → Store in environment variable in plaintext
   → Check into git
   → Log to any output
   → Transmit over the network

Where the Public Key Travels

Public key distribution path:

ServerSSO private key
       │
       │ RSA key generation (one-time)
       ▼
ServerSSO public key  ←── stored alongside private key
       │
       │ Exposed at JWKS endpoint (intentionally public)
       ▼
GET https://sso.example.com/.well-known/jwks.json
       │
       ├──────────────────────────────▶  WebA caches public key (1 hour TTL)
       │
       └──────────────────────────────▶  WebB caches public key (1 hour TTL)

The public key contains ONLY:
  "n": RSA modulus  (large integer — public component)
  "e": RSA exponent (usually 65537 — public component)
  "kid": key ID
  "alg": RS256
  "use": sig

The public key does NOT contain:
  "d": private exponent  (this stays on ServerSSO only)
  "p", "q": prime factors (this stays on ServerSSO only)

Step-by-Step Key Operations During JWT Lifecycle

STEP 1 — JWT Creation (ServerSSO, private key)
──────────────────────────────────────────────
ServerSSO assembles:
  header  = { "alg": "RS256", "kid": "key-2024-01", "typ": "JWT" }
  payload = { "sub": "user-uid-456", "iss": "https://sso.example.com", ... }

  signing_input = base64url(header) + "." + base64url(payload)

  🔐 signature = RSA_PKCS1v15_SIGN(
        key   = private_key,
        hash  = SHA256(signing_input)
     )

  JWT = signing_input + "." + base64url(signature)

STEP 2 — JWT Transmission
──────────────────────────────────────────────
ServerSSO → WebA/WebB: JWT returned in /token response (HTTPS)
WebA/WebB stores JWT in Redis session — browser never sees it
WebA/WebB sends JWT to resource APIs as Bearer token (HTTPS)

STEP 3 — JWT Verification (WebA or WebB, public key)
──────────────────────────────────────────────────────
Receiver (WebA/WebB) performs:

  1. Split JWT into header, payload, signature
  2. Decode header → extract "kid" = "key-2024-01"
  3. Fetch public key from JWKS cache matching kid
  4. Reconstruct signing_input = header + "." + payload

  🔓 valid = RSA_PKCS1v15_VERIFY(
        key       = public_key,
        hash      = SHA256(signing_input),
        signature = base64url_decode(signature_part)
     )

  5. If valid == true → signature is authentic → trust payload claims
  6. Validate iss, aud, exp, nbf, iat, nonce, scope

Key Rotation

RSA keys should be rotated periodically (every 90 days recommended). ServerSSO supports multiple active keys in JWKS simultaneously during rotation:

During key rotation, JWKS contains TWO keys:

GET /.well-known/jwks.json
{
  "keys": [
    {
      "kid": "key-2024-01",   ← OLD key (still valid, being phased out)
      "kty": "RSA", "alg": "RS256", "use": "sig",
      "n": "old-modulus...", "e": "AQAB"
    },
    {
      "kid": "key-2025-01",   ← NEW key (now used for signing)
      "kty": "RSA", "alg": "RS256", "use": "sig",
      "n": "new-modulus...", "e": "AQAB"
    }
  ]
}

Rotation process:
  1. Generate new RSA key pair on ServerSSO
  2. Add new public key to JWKS (both keys now published)
  3. Start signing NEW tokens with new private key
  4. Old tokens (signed with old key) still verifiable via old public key in JWKS
  5. Wait for all old tokens to expire (max access_token TTL = 15 min)
  6. Remove old public key from JWKS
  7. Decommission old private key

WebA and WebB handle this automatically:
  - If JWT header.kid not found in cache → re-fetch JWKS → find new key → verify
  - No restart, no config change required

Summary: Who Uses Which Key and Why

╔══════════════╦════════════════╦══════════════════════════════════╦══════════════════════════╗
║  Component   ║   Key Used     ║         Operation                ║   Tokens Affected        ║
╠══════════════╬════════════════╬══════════════════════════════════╬══════════════════════════╣
║  ServerSSO   ║ 🔐 PRIVATE KEY ║ Signs JWT payload + header       ║ access_token             ║
║              ║                ║ (RSA-SHA256 signature)           ║ id_token                 ║
║              ║                ║                                  ║ logout_token             ║
╠══════════════╬════════════════╬══════════════════════════════════╬══════════════════════════╣
║  ServerSSO   ║ 🔓 PUBLIC KEY  ║ Publishes n+e in JWKS endpoint   ║ (key material, not token)║
║              ║                ║ for consumers to fetch           ║                          ║
╠══════════════╬════════════════╬══════════════════════════════════╬══════════════════════════╣
║  WebA        ║ 🔓 PUBLIC KEY  ║ Verifies id_token signature      ║ id_token (from SSO)      ║
║  (fetched    ║ (from JWKS)    ║ Verifies logout_token signature  ║ logout_token (from SSO)  ║
║   and cached)║                ║ Optionally verifies access_token ║ access_token (optional)  ║
╠══════════════╬════════════════╬══════════════════════════════════╬══════════════════════════╣
║  WebB        ║ 🔓 PUBLIC KEY  ║ Verifies id_token signature      ║ id_token (from SSO)      ║
║  (fetched    ║ (from JWKS)    ║ Verifies logout_token signature  ║ logout_token (from SSO)  ║
║   and cached)║                ║ Optionally verifies access_token ║ access_token (optional)  ║
╚══════════════╩════════════════╩══════════════════════════════════╩══════════════════════════╝

KEY RULE:  ServerSSO is the ONLY component that ever touches the private key.
           WebA and WebB ONLY ever hold and use the public key.
           The private key NEVER travels over a network.
           The public key is INTENTIONALLY distributed to all verifiers.

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 the sso_session_id as 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. Both are signed with the same RSA private key but carry different audience and scope claims:

JWT issued to WebA (via web-a-001 client):
  🔐 SIGNED with ServerSSO RSA PRIVATE KEY  (kid: "key-2024-01")
{
  "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):
  🔐 SIGNED with ServerSSO RSA PRIVATE KEY  (kid: "key-2024-01") ← same private key
{
  "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
}

Verification by WebA or WebB:
  🔓 VERIFY with ServerSSO RSA PUBLIC KEY  (fetched from JWKS, cached)
  Both apps use the same public key from the same JWKS endpoint.

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:

  1. Creates SSO session SSO-XYZ-789
  2. Records authenticated_clients: [] (no apps yet)
  3. Generates authorization code CODE-A-111 bound to web-a-001
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 to sso.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:

  1. client_id + client_secret
  2. code valid, not expired, not reused ✅
  3. redirect_uri matches exactly ✅

Step A11 — ServerSSO Returns Tokens to WebA

🔐 PRIVATE KEY USED HERE — ServerSSO signs both JWTs with RSA private key
kid: “key-2024-01” is embedded in each JWT header so verifiers know which public key to use.
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"
  }
 
  JWT header (both tokens):  { "alg": "RS256", "kid": "key-2024-01", "typ": "JWT" }
  Signature computed with:   🔐 ServerSSO RSA PRIVATE KEY

ServerSSO also updates its SSO session:

SSO session "SSO-XYZ-789":
  authenticated_clients: ["web-a-001"]   ← WebA added
🔓 PUBLIC KEY USED HERE — WebA fetches JWKS and verifies id_token signature
WebA calls GET https://sso.example.com/.well-known/jwks.json, finds the key
matching kid: “key-2024-01”, then verifies the RSA signature on the id_token.
If verification passes, WebA trusts the claims (sub, email, nonce, aud).
WebA stores in Redis:
  Key: "weba:SESS-A-abc123"
  Value: {
    sub:              "user-uid-456",
    email:            "alice@example.com",
    access_token:     "JWT-A",              ← signed by 🔐 private key
    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
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

🔐 PRIVATE KEY USED HERE — ServerSSO signs new JWTs for WebB with the same RSA private key
JWT-B is a brand-new token with different aud and scope from JWT-A, but signed
by the same private key (identified by kid: “key-2024-01” in the header).
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"
  }
 
  JWT header (both tokens):  { "alg": "RS256", "kid": "key-2024-01", "typ": "JWT" }
  Signature computed with:   🔐 ServerSSO RSA PRIVATE KEY  (same key as JWT-A)

Note: JWT-B has aud: [“https://resource-b.example.com”] and scope: “api:resourceB” — completely different from JWT-A.

🔓 PUBLIC KEY USED HERE — WebB verifies id_token signature
WebB fetches JWKS (or uses its own cache), finds kid: “key-2024-01”,
verifies the id_token RSA signature, then checks aud == “web-b-001”
and nonce == stored nonce. WebB uses the same public key as WebA —
both share the same JWKS endpoint.
WebB stores in Redis:
  Key: "webb:SESS-B-def456"
  Value: {
    sub:              "user-uid-456",
    email:            "alice@example.com",
    access_token:     "JWT-B",              ← signed by 🔐 private key
    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

🔐 PRIVATE KEY USED HERE — ServerSSO signs fresh JWTs with the RSA private key
The new access_token is a freshly minted JWT with updated iat and exp claims,
signed again with the private key. The kid in the header is unchanged
(unless key rotation just happened).
{
  "access_token":  "eyJhbGci...(new JWT-A)",
  "expires_in":    900,
  "refresh_token": "rtA-new-rotated-token",   ← old token invalidated
  "scope":         "openid profile email api:resourceA"
}
 
  Signature computed with:  🔐 ServerSSO RSA PRIVATE KEY

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)
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   → ServerSSO    :  🔐 SIGN JWT-A + id_token-A with RSA PRIVATE KEY
14.  ServerSSO   → WebA         :  { JWT-A, id_token-A, refresh-A }
15.  WebA        → WebA         :  🔓 VERIFY id_token-A signature with RSA PUBLIC KEY (JWKS)
16.  WebA        → WebA         :  validate id_token-A claims (aud=web-a-001, nonce=nonce-A)
17.  WebA        → Redis        :  store SESS-A { JWT-A, refresh-A, sso_sid=SSO-XYZ }
18.  WebA        → Browser      :  Set-Cookie: session_a + 302 → /page
19.  Browser     → WebA         :  GET /page  (Cookie: session_a=SESS-A)
20.  WebA        → Browser      :  200 HTML

── Alice now opens WebB ──

21.  Browser     → WebB         :  GET /page  (no session_b)
22.  WebB        → WebB         :  generate state-B, nonce-B; store in Redis
23.  WebB        → Browser      :  302 → /authorize?client_id=web-b-001&state=state-B
24.  Browser     → ServerSSO    :  GET /authorize  (Cookie: sso_session=SSO-XYZ ✅)
25.  ServerSSO   → ServerSSO    :  SSO session valid → skip login → issue CODE-B
26.  ServerSSO   → Browser      :  302 → WebB/callback?code=CODE-B  (no login page!)
27.  Browser     → WebB         :  GET /callback?code=CODE-B&state=state-B
28.  WebB        → WebB         :  validate state-B == stored; retrieve nonce-B
29.  WebB        → ServerSSO    :  POST /token (code=CODE-B, client_secret-B)  [s2s]
30.  ServerSSO   → ServerSSO    :  🔐 SIGN JWT-B + id_token-B with RSA PRIVATE KEY (same key)
31.  ServerSSO   → WebB         :  { JWT-B, id_token-B, refresh-B }
32.  WebB        → WebB         :  🔓 VERIFY id_token-B signature with RSA PUBLIC KEY (JWKS)
33.  WebB        → WebB         :  validate id_token-B claims (aud=web-b-001, nonce=nonce-B)
34.  WebB        → Redis        :  store SESS-B { JWT-B, refresh-B, sso_sid=SSO-XYZ }
35.  WebB        → Browser      :  Set-Cookie: session_b + 302 → /page
36.  Browser     → WebB         :  GET /page  (Cookie: session_b=SESS-B)
37.  WebB        → Browser      :  200 HTML

KEY USAGE SUMMARY FOR THIS FLOW:
  Steps 13, 30  →  🔐 ServerSSO RSA PRIVATE KEY   (signing)
  Steps 15, 32  →  🔓 RSA PUBLIC KEY from JWKS     (verification)

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

🔓 PUBLIC KEY USED HERE — WebA and WebB verify the logout_token signature before acting on it
This is critical: without signature verification, any party could forge a logout request
and force-terminate user sessions. The public key guarantees the logout_token genuinely
came from ServerSSO.
# 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 (public key)
    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:

  1. WebA's own session
  2. The SSO session on ServerSSO
  3. 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
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                          ║
╠═════════════════════════════════════════════════════════════════════════╣
║ RSA KEY USAGE — WHO USES WHAT                                           ║
║                                                                         ║
║  🔐 PRIVATE KEY  →  ServerSSO ONLY                                     ║
║     Used to SIGN:  access_token, id_token, logout_token                ║
║     Stored in:     HSM / KMS / encrypted store on ServerSSO            ║
║     NEVER shared, NEVER leaves ServerSSO                               ║
║                                                                         ║
║  🔓 PUBLIC KEY   →  WebA, WebB (fetched from JWKS, cached 1 hr)        ║
║     Used to VERIFY: id_token signature (after /token exchange)         ║
║                     logout_token signature (backchannel logout)        ║
║                     access_token signature (optional, if inspected)    ║
║     Fetched from: GET /sso.example.com/.well-known/jwks.json           ║
║     kid "key-2024-01" links JWT header → correct key in JWKS           ║
╠═════════════════════════════════════════════════════════════════════════╣
║ WHEN EACH KEY IS USED (step reference)                                  ║
║   Scenario 1, Step A11 → 🔐 SSO signs JWT-A + id_token-A              ║
║   Scenario 1, Step A12 → 🔓 WebA verifies id_token-A                  ║
║   Scenario 1, Step B9  → 🔐 SSO signs JWT-B + id_token-B              ║
║   Scenario 1, Step B10 → 🔓 WebB verifies id_token-B                  ║
║   Scenario 2B,  Step 3 → 🔐 SSO signs new JWT-A (refresh)             ║
║   Logout, Step 7       → 🔓 WebB verifies logout_token                ║
╠═════════════════════════════════════════════════════════════════════════╣
║ 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               ║
║   Both signed by same 🔐 private key; both verified by same 🔓 pub key ║
╠═════════════════════════════════════════════════════════════════════════╣
║ 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 signs logout_token (🔐 private key) for each registered client ║
║   → WebA + WebB verify logout_token (🔓 public key) then delete sessions║
║   → 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 Version: 2.0 — Added RSA Key Usage section (private key / public key per component and step) Standard: OpenID Connect Core 1.0 · OpenID Connect Back-Channel Logout 1.0 · RFC 6749 · RFC 7519 · RFC 7517

security/sso-web.txt · Last modified: by phong2018