Version: 1.0
Date: 2026-06-16
Audience: Backend Engineers, Mobile Developers, Security Architects
===== Overview ===== overview
This document describes a complete OpenID Connect (OIDC) Single Sign-On (SSO) system designed for a mobile application that communicates with two backend resource servers.
The system uses:
===== Architecture & Components ===== architecture
| Component | Role | Typical Stack |
|---|---|---|
| Mobile App | End-user client. Initiates auth, holds tokens, calls resource servers. | iOS / Android / React Native |
| ServerSSO | OIDC Authorization Server. Issues tokens, manages sessions, exposes JWKS. | Keycloak / Auth0 / Custom |
| ServerA | Resource Server A. Protected API. Validates JWT on each request. | Node.js / Spring / Django |
| ServerB | Resource Server B. Protected API. Validates JWT on each request. | Node.js / Spring / Django |
┌─────────────┐ OIDC / PKCE ┌─────────────────┐
│ Mobile App │ ◄──────────────────────────► │ ServerSSO │
└──────┬──────┘ │ (Auth Server) │
│ └────────┬────────┘
│ JWT (Bearer Token) │ JWKS Public Keys
├────────────────────────────────► ┌──────────┴──────────┐
│ │ ServerA │
│ JWT (Bearer Token) │ (Resource API) │
└────────────────────────────────► └─────────────────────┘
┌─────────────────────┐
│ ServerB │
│ (Resource API) │
└─────────────────────┘
Note: ServerA and ServerB never contact ServerSSO during normal request validation.
They verify JWT signatures locally using the public keys fetched from the JWKS endpoint.
===== Key Concepts ===== key-concepts
PKCE prevents authorization code interception attacks that are common on mobile platforms (because redirect URIs on mobile can be hijacked by malicious apps).
| Step | PKCE Parameter | Description |
|---|---|---|
| 1 | ``code_verifier`` | Random secret (43–128 chars) generated by the Mobile App |
| 2 | ``code_challenge`` | SHA-256 hash of code_verifier, base64url-encoded |
| 3 | Auth request | App sends ``code_challenge`` + ``code_challenge_method=S256`` |
| 4 | Token exchange | App sends original ``code_verifier`` to prove identity |
A JWT has three parts separated by dots: ``header.payload.signature``
// Header
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-id-001" // key ID — used to look up the right key in JWKS
}
// Payload (claims)
{
"iss": "https://sso.example.com", // issuer
"sub": "user-uuid-123", // subject (user ID)
"aud": ["serverA", "serverB"], // intended audiences
"exp": 1718500000, // expiry (Unix timestamp)
"iat": 1718496400, // issued at
"email": "user@example.com",
"roles": ["read", "write"]
}
// Signature: RS256(base64url(header) + "." + base64url(payload), privateKey)
ServerSSO exposes a public endpoint:
GET https://sso.example.com/.well-known/jwks.json
Response:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "key-id-001",
"alg": "RS256",
"n": "0vx7agoebGcQ...",
"e": "AQAB"
}
]
}
ServerA and ServerB cache this response and use it to verify JWT signatures without calling ServerSSO on every request.
ServerSSO also exposes:
GET https://sso.example.com/.well-known/openid-configuration
This returns all endpoint URLs (authorization, token, userinfo, jwks_uri, etc.).
===== Scenario 1 — Not Authenticated (First Login) ===== scenario1
This scenario covers the complete first-time login flow from scratch.
The app generates cryptographic values locally, before any network call:
code_verifier = base64url( random_bytes(32) )
e.g. "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = base64url( SHA256( code_verifier ) )
e.g. "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
state = base64url( random_bytes(16) ) // CSRF protection
nonce = base64url( random_bytes(16) ) // replay protection
The app stores ``code_verifier``, ``state``, and ``nonce`` in memory (never on disk).
The app opens the system browser (or in-app browser tab) and navigates to:
GET https://sso.example.com/authorize ?response_type=code &client_id=mobile-app-client &redirect_uri=myapp://callback &scope=openid%20profile%20email%20offline_access &state=<state> &nonce=<nonce> &code_challenge=<code_challenge> &code_challenge_method=S256
| Parameter | Value | Purpose |
|---|---|---|
| ``response_type`` | ``code`` | Request an authorization code |
| ``client_id`` | ``mobile-app-client`` | Identifies this application |
| ``redirect_uri`` |
The app:
The app makes a back-channel (direct HTTPS) POST request:
POST https://sso.example.com/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=SplxlOBeZQQYbYS6WxSbIA &redirect_uri=myapp://callback &client_id=mobile-app-client &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
Critical: ``code_verifier`` is sent here for the first time.
ServerSSO recomputes SHA256(code_verifier) and compares it to the stored ``code_challenge``.
If they match, the exchange is authorized. This proves the requester is the same
entity that started the flow — defeating code interception attacks.
ServerSSO responds with:
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ...",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": "eyJhbGciOiJSUzI1NiJ9...",
"refresh_token": "8xLOxBtZp8",
"scope": "openid profile email offline_access"
}
| Token | Purpose | Storage on Device |
|---|---|---|
| ``access_token`` | Sent to ServerA / ServerB as Bearer token | Secure memory only |
| ``id_token`` | Contains user identity claims (verified by app) | Secure memory only |
| ``refresh_token`` | Used to get new access_token when expired | Encrypted secure storage |
The app validates the id_token:
GET https://serverA.example.com/api/data Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ...
ServerA validates the JWT entirely locally:
1. Parse JWT header → extract "kid" = "key-id-001" 2. Look up public key in local JWKS cache by kid (If not cached: fetch https://sso.example.com/.well-known/jwks.json and cache) 3. Verify RS256 signature using the RSA public key 4. Validate claims: - exp > now() → not expired - iss == "https://sso.example.com" → correct issuer - aud contains "serverA" → token is intended for this server 5. Extract sub, roles, email from payload 6. Apply authorization rules 7. Return API response
The same access_token is reused for ServerB:
GET https://serverB.example.com/api/resource Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ...
ServerB performs identical JWT validation (Steps 8.1–8.6) — this is the SSO benefit: one authentication, multiple resource servers, no re-login required.
Mobile App ServerSSO ServerA ServerB
│ │ │ │
│─ Generate PKCE ──►│ │ │
│ code_verifier │ │ │
│ code_challenge │ │ │
│ │ │ │
│── GET /authorize ─►│ │ │
│ (code_challenge) │ │ │
│ │ │ │
│ │◄── User Login ─────│ │
│ │ (browser) │ │
│ │ │ │
│◄── redirect ──────│ │ │
│ ?code=XYZ │ │ │
│ &state=ABC │ │ │
│ │ │ │
│── POST /token ────►│ │ │
│ code_verifier │ │ │
│ │ verify: SHA256( │ │
│ │ code_verifier) │ │
│ │ == code_challenge │ │
│ │ │ │
│◄── access_token ──│ │ │
│ id_token │ │ │
│ refresh_token │ │ │
│ │ │ │
│── GET /api/data ──────────────────────►│ │
│ Bearer: JWT │ │ validate JWT │
│ │ │ (JWKS cache) │
│◄── 200 response ──────────────────────►│ │
│ │ │ │
│── GET /api/res ────────────────────────────────────────►│
│ Bearer: JWT │ │ │ validate JWT
│◄── 200 response ────────────────────────────────────────│ (JWKS cache)
│ │ │ │
===== Scenario 2 — Already Authenticated (SSO Token Reuse) ===== scenario2
This scenario covers the case where the user already has a valid session or the app has a cached refresh_token and needs a new access_token.
The simplest case: the app already holds a non-expired ``access_token``.
Before making any API call, the app checks locally:
decoded = JWT.decode(access_token, verify_signature=false) // decode without verification
now = current_unix_timestamp()
if decoded.exp > now + 30: // 30-second buffer
// Token is still valid — use it directly
proceed_to_step_2()
else:
// Token expired or about to expire — refresh it (Sub-Scenario 2b)
refresh_access_token()
Identical to Scenario 1 Steps 7–9. The app reuses the cached ``access_token``:
GET https://serverA.example.com/api/data Authorization: Bearer <cached_access_token>
No interaction with ServerSSO is needed. ✓
The ``access_token`` has expired, but the app holds a valid ``refresh_token``.
POST https://sso.example.com/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token &refresh_token=8xLOxBtZp8 &client_id=mobile-app-client
Note: No PKCE is required for the refresh grant — PKCE was only needed
for the initial authorization code exchange.
ServerSSO checks:
If valid, ServerSSO rotates the refresh token (issues a new one, invalidates the old one).
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ...(new)",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": "eyJhbGciOiJSUzI1NiJ9...(new)",
"refresh_token": "9yMpACuQr9",
"scope": "openid profile email offline_access"
}
The app stores the new ``refresh_token`` and new ``access_token``.
The app proceeds with the freshly issued ``access_token``.
GET https://serverA.example.com/api/data Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ...(new)
If the ``refresh_token`` has also expired (e.g., user was inactive for 30+ days):
The token endpoint returns:
HTTP 400 Bad Request
{
"error": "invalid_grant",
"error_description": "Refresh token expired or revoked"
}
Before forcing a full re-login, the app attempts silent authentication:
GET https://sso.example.com/authorize ?response_type=code &client_id=mobile-app-client &redirect_uri=myapp://callback &scope=openid profile email offline_access &prompt=none ← key parameter: no UI shown &code_challenge=<new_code_challenge> &code_challenge_method=S256 &state=<new_state>
Mobile App ServerSSO ServerA ServerB
│ │ │ │
│ [access_token expired, refresh_token valid] │
│ │ │ │
│── POST /token ────►│ │ │
│ grant_type= │ │ │
│ refresh_token │ │ │
│ │ validate RT │ │
│ │ rotate RT │ │
│◄── new tokens ────│ │ │
│ access_token │ │ │
│ refresh_token │ │ │
│ (rotated) │ │ │
│ │ │ │
│── GET /api/data ──────────────────────►│ │
│ Bearer: new JWT │ │ validate JWT │
│◄── 200 OK ─────────────────────────────│ │
│ │ │ │
│── GET /api/res ────────────────────────────────────────►│
│ Bearer: new JWT │ │ │ validate JWT
│◄── 200 OK ──────────────────────────────────────────────│
===== Token Validation with JWKS (ServerA & ServerB Detail) ===== token-validation
Resource servers should not fetch JWKS on every request. Recommended strategy:
| Condition | Action |
|---|---|
| Startup | Fetch JWKS and cache in memory |
| JWT ``kid`` found in cache | Use cached key — no network call |
| JWT ``kid`` not found in cache | Fetch JWKS again (key rotation may have occurred) |
| Cache age > 1 hour | Refresh JWKS in background |
| JWKS fetch fails | Use stale cache; log warning; retry with backoff |
function validateJWT(token, audience):
// 1. Split and decode (no verification yet)
[header_b64, payload_b64, signature_b64] = token.split(".")
header = base64url_decode(header_b64)
payload = base64url_decode(payload_b64)
// 2. Look up the signing key
kid = header.kid
key = jwksCache.get(kid)
if key is null:
jwksCache.refresh() // re-fetch from ServerSSO
key = jwksCache.get(kid)
if key is null:
throw InvalidTokenError("Unknown kid")
// 3. Verify signature
message = header_b64 + "." + payload_b64
if NOT RSA_SHA256_verify(message, signature_b64, key.publicKey):
throw InvalidTokenError("Bad signature")
// 4. Validate standard claims
if payload.exp < now():
throw InvalidTokenError("Token expired")
if payload.iss != "https://sso.example.com":
throw InvalidTokenError("Wrong issuer")
if audience NOT IN payload.aud:
throw InvalidTokenError("Wrong audience")
if payload.nbf is set AND payload.nbf > now():
throw InvalidTokenError("Token not yet valid")
// 5. Extract and return user context
return { userId: payload.sub, roles: payload.roles, email: payload.email }
When ServerSSO rotates its signing key:
===== Security Notes ===== security-notes
| Token | Recommended Storage | Never store in |
|---|---|---|
| access_token | In-memory only (lost on app restart) | SharedPreferences (Android) / UserDefaults (iOS) without encryption |
| refresh_token | iOS Keychain / Android Keystore (hardware-backed) | Plain files, AsyncStorage (unencrypted) |
| id_token | In-memory only (only needed at login) | Anywhere persistent |