Table of Contents
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
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 ===== 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 ===== 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) ===== 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=<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:
- 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) ===== 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 <cached_access_token>
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=<new_code_challenge> &code_challenge_method=S256 &state=<new_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) ===== 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 ===== 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 ===== 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
