====== OIDC SSO System: Mobile App + PKCE + JWT + JWKS ======
**Version:** 1.0 \\
**Date:** 2026-06-16 \\
**Audience:** Backend Engineers, Mobile Developers, Security Architects
----
===== Table of Contents =====
- [[#overview|Overview]]
- [[#architecture|Architecture & Components]]
- [[#key-concepts|Key Concepts]]
- [[#scenario1|Scenario 1 — Not Authenticated (First Login)]]
- [[#scenario2|Scenario 2 — Already Authenticated (SSO Token Reuse)]]
- [[#token-validation|Token Validation with JWKS]]
- [[#sequence-diagrams|Sequence Diagrams (Text)]]
- [[#security-notes|Security Notes]]
- [[#glossary|Glossary]]
----
===== Overview ===== {{anchor: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:
* **PKCE** (Proof Key for Code Exchange) — prevents authorization code interception on mobile
* **JWT** (JSON Web Token) — compact, self-contained token format
* **JWKS** (JSON Web Key Set) — public key endpoint for token signature verification
* **OIDC** — identity layer on top of OAuth 2.0
----
===== Architecture & Components ===== {{anchor:architecture}}
==== Component List ====
^ 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 |
==== Network Overview ====
┌─────────────┐ 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 ===== {{anchor:key-concepts}}
==== PKCE Flow (Mobile-Specific) ====
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 |
==== JWT Structure ====
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)
==== JWKS Endpoint ====
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.
==== OIDC Discovery Document ====
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) ===== {{anchor:scenario1}}
This scenario covers the **complete first-time login flow** from scratch.
==== Step-by-Step ====
=== Step 1: Mobile App — Generate PKCE Parameters ===
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).
=== Step 2: Mobile App → ServerSSO — Authorization Request ===
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=
&nonce=
&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`` | ``myapp://callback`` | Deep link back to the app |
| ``scope`` | ``openid profile email offline_access`` | Requested permissions |
| ``state`` | Random value | CSRF protection |
| ``nonce`` | Random value | Replay attack protection |
| ``code_challenge`` | SHA256(code_verifier) | PKCE challenge |
| ``code_challenge_method``| ``S256`` | Hash algorithm |
=== Step 3: ServerSSO — Authenticate the User ===
ServerSSO presents the **login page** (username/password, MFA, social login, etc.).
The user authenticates successfully. ServerSSO:
- Validates the PKCE challenge parameters
- Creates a server-side session
- Generates a short-lived **authorization code** (e.g., valid for 60 seconds, single-use)
=== Step 4: ServerSSO → Mobile App — Authorization Code Redirect ===
ServerSSO redirects the browser to the app's deep link:
myapp://callback
?code=SplxlOBeZQQYbYS6WxSbIA
&state=
The app:
- Receives the redirect via the OS URL scheme handler
- **Validates** that ``state`` matches what was stored in Step 1 (CSRF check)
- Extracts the ``code``
=== Step 5: Mobile App → ServerSSO — Token Exchange (PKCE Verification) ===
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.
=== Step 6: ServerSSO → Mobile App — Token Response ===
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**:
- Fetch JWKS from ``https://sso.example.com/.well-known/jwks.json``
- Verify RS256 signature using the public key matching ``kid``
- Check ``iss``, ``aud``, ``exp``, and ``nonce`` (nonce must match Step 1)
=== Step 7: Mobile App → ServerA — API Request with JWT ===
GET https://serverA.example.com/api/data
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ...
=== Step 8: ServerA — JWT Validation (Local, No SSO Call) ===
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
=== Step 9: Mobile App → ServerB — API Request with Same JWT ===
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.
==== Scenario 1 — Summary Flow ====
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) ===== {{anchor: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.
==== Sub-Scenario 2a: Valid Access Token Still in Memory ====
The simplest case: the app already holds a non-expired ``access_token``.
=== Step 1: Mobile App — Check Token Expiry ===
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()
=== Step 2: Mobile App → ServerA / ServerB — API Request ===
Identical to Scenario 1 Steps 7–9. The app reuses the cached ``access_token``:
GET https://serverA.example.com/api/data
Authorization: Bearer
No interaction with ServerSSO is needed. ✓
----
==== Sub-Scenario 2b: Access Token Expired — Refresh Flow ====
The ``access_token`` has expired, but the app holds a valid ``refresh_token``.
=== Step 1: Mobile App → ServerSSO — Refresh Token Request ===
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.
=== Step 2: ServerSSO — Validate Refresh Token ===
ServerSSO checks:
- Refresh token exists in its database and is not revoked
- Refresh token has not expired (typically 30 days for mobile apps)
- ``client_id`` matches
If valid, ServerSSO **rotates** the refresh token (issues a new one, invalidates the old one).
=== Step 3: ServerSSO → Mobile App — New Tokens ===
{
"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``.
=== Step 4: Mobile App → ServerA / ServerB — API Request ===
The app proceeds with the freshly issued ``access_token``.
GET https://serverA.example.com/api/data
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ...(new)
----
==== Sub-Scenario 2c: Both Tokens Expired — Re-Authentication ====
If the ``refresh_token`` has also expired (e.g., user was inactive for 30+ days):
=== Step 1: Mobile App — Detect Expired Refresh Token ===
The token endpoint returns:
HTTP 400 Bad Request
{
"error": "invalid_grant",
"error_description": "Refresh token expired or revoked"
}
=== Step 2: Check for Existing SSO Session (Silent Auth) ===
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=
&code_challenge_method=S256
&state=
* If ServerSSO has a **valid browser/SSO session** (cookie): it returns a new ``code`` immediately — no login prompt → proceed with token exchange (Scenario 1, Steps 5–6)
* If no session exists: ServerSSO returns ``error=login_required`` → app must show login UI → full Scenario 1 from Step 2
==== Scenario 2b — Summary Flow ====
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) ===== {{anchor:token-validation}}
==== JWKS Caching Strategy ====
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 |
==== JWT Validation Algorithm (Pseudocode) ====
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 }
==== Key Rotation ====
When ServerSSO rotates its signing key:
- New JWTs are signed with the **new** key (new ``kid``)
- Old JWTs remain valid until they expire (old key stays in JWKS temporarily)
- ServerA/ServerB fetch the new JWKS when they encounter an unknown ``kid``
- No downtime or coordination required
----
===== Security Notes ===== {{anchor:security-notes}}
==== Token Storage on Mobile ====
^ 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 |
==== PKCE Requirements ====
* ``code_verifier``: must be 43–128 characters, using ``[A-Z a-z 0-9 - . _ ~]``
* Always use ``S256`` (SHA-256) — never ``plain`` (insecure)
* Generate a **new** ``code_verifier`` for every authorization request
* Never reuse or persist ``code_verifier`` across sessions
==== Redirect URI Security ====
* Register **exact** redirect URIs in ServerSSO (no wildcards)
* Use **private-use URI schemes** (e.g., ``com.example.myapp://callback``) or HTTPS with Universal Links (iOS) / App Links (Android)
* Always validate ``state`` parameter to prevent CSRF
==== Access Token Lifetime Recommendations ====
^ Token ^ Recommended Lifetime ^ Notes ^
| access_token | 15–60 minutes | Short life limits exposure if intercepted |
| refresh_token | 7–30 days | Rotate on each use (refresh token rotation) |
| id_token | 15–60 minutes | Same as access_token |
==== HTTPS Enforcement ====
All endpoints (authorization, token, userinfo, JWKS, and resource APIs) **must** use HTTPS.
Certificate pinning is recommended for the mobile app in high-security environments.
----
===== Glossary ===== {{anchor:glossary}}
^ Term ^ Definition ^
| OIDC | OpenID Connect — identity layer on top of OAuth 2.0 |
| OAuth 2.0 | Authorization framework; OIDC extends it with identity (id_token) |
| PKCE | Proof Key for Code Exchange — RFC 7636; prevents code interception on mobile |
| JWT | JSON Web Token — RFC 7519; compact, URL-safe token format |
| JWKS | JSON Web Key Set — RFC 7517; public key set exposed by the auth server |
| access_token | Short-lived credential presented to resource servers |
| id_token | JWT containing user identity claims; consumed by the client only |
| refresh_token | Long-lived credential used to obtain new access tokens |
| code_verifier | Random secret generated by the client for PKCE |
| code_challenge | SHA-256 hash of code_verifier, sent in the authorization request |
| authorization code | Short-lived, single-use code exchanged for tokens |
| kid | Key ID — identifies which key in the JWKS was used to sign a JWT |
| SSO | Single Sign-On — one authentication grants access to multiple services |
| Bearer token | HTTP authentication scheme; token is presented as-is in the Authorization header |
| RS256 | RSA Signature with SHA-256; asymmetric signing algorithm for JWTs |
----
//Document maintained by the Platform Security Team. Last updated: 2026-06-16//