This is an old revision of the document!
Table of Contents
OIDC SSO System — Mobile App + PKCE + JWT + JWKS
Document Version: 1.0 Last Updated: 2026-06-16 Scope: Single Sign-On architecture for a native Mobile Application (iOS / Android)
using Authorization Code Flow with PKCE, JWT access tokens validated via JWKS
on ServerA and ServerB. Covers RSA key usage at every component.
Table of Contents
Architecture Overview
┌──────────────────────────────────────────────────────────────────────────────────┐ │ SYSTEM TOPOLOGY │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ Mobile Device │ │ │ │ │ │ │ │ ┌────────────────────────────────────────────┐ │ │ │ │ │ Native Mobile App │ │ │ │ │ │ (iOS / Android — Public OIDC Client) │ │ │ │ │ │ │ │ │ │ │ │ Token Storage (Secure Enclave / Keystore):│ │ │ │ │ │ ┌──────────────────────────────────────┐ │ │ │ │ │ │ │ access_token → Memory only │ │ │ │ │ │ │ │ refresh_token → Secure storage │ │ │ │ │ │ │ │ id_token → Memory only │ │ │ │ │ │ │ └──────────────────────────────────────┘ │ │ │ │ │ └───────────┬──────────────┬─────────────────┘ │ │ │ │ │ │ │ │ │ │ ┌───────────▼──────────┐ │ Custom URI / ASB │ │ │ │ │ System Browser / │ │ redirect scheme │ │ │ │ │ In-App Browser Tab │ │ │ │ │ │ │ (ASWebAuthentication│ │ │ │ │ │ │ Session / CCT) │ │ │ │ │ │ └──────────────────────┘ │ │ │ │ └──────────────────────────────┼───────────────────────┘ │ │ │ HTTPS (REST API) │ │ ┌──────────────────┼────────────────┐ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────────────┐ │ │ │ ServerA │ │ ServerB │ │ ServerSSO │ │ │ │ (Resource API) │ │ (Resource API) │ │ (OIDC Identity Provider) │ │ │ │ │ │ │ │ │ │ │ │ 🔓 Public Key │ │ 🔓 Public Key │ │ 🔐 Private Key (sign JWTs) │ │ │ │ (JWKS cache) │ │ (JWKS cache) │ │ 🔓 Public Key (JWKS endpt) │ │ │ │ JWT validation │ │ JWT validation │ │ /authorize /token /jwks │ │ │ └──────────────────┘ └──────────────────┘ └──────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────────────────────┘
Core principle for mobile: The mobile app is a public client — it cannot securely store a client_secret (the app binary can be decompiled). PKCE replaces the client_secret as the proof of legitimacy for the token exchange. The authorization flow opens in the system browser (not a WebView), which provides isolation from the app's process and access to any existing browser SSO session.
Why Mobile is Different
| Aspect | SPA (Browser) | Traditional Web | Mobile App |
|---|---|---|---|
| Client type | Public (no secret) | Confidential (has secret) | Public (no secret — binary is exposed) |
| PKCE | Required | Optional | Required (RFC 8252 mandates it) |
| Login UI | Browser redirect | Server-rendered page | System browser / ASWebAuthSession |
| Token storage | Memory / sessionStorage | Server-side Redis/DB | Secure Enclave / Android Keystore |
| Redirect after login | Browser URL change | HTTP 302 redirect | Custom URI scheme / App Link |
| Background token refresh | Silent iframe / JS | Server-side auto-refresh | Refresh token in secure storage |
| App lifecycle risk | Tab close loses memory state | Server session persists | App to background loses memory only |
| Network calls | Same origin / CORS | Server-to-server | Direct HTTPS from device |
| Biometric gate | Not available | Not available | Can gate token access with biometric |
Mobile-Specific Standards
- RFC 8252 — OAuth 2.0 for Native Apps (mandates system browser, forbids WebView for login)
- RFC 7636 — PKCE (Proof Key for Code Exchange)
- RFC 6749 — OAuth 2.0 Authorization Framework
- OpenID Connect Core 1.0 — Identity layer on top of OAuth 2.0
Components
Mobile App
| Property | Value |
|---|---|
| Role | OIDC Relying Party — Public Client |
| Platform | iOS (Swift/ObjC) / Android (Kotlin/Java) / React Native |
| Client type | Public — NO client_secret |
| Auth method | PKCE with S256 (mandatory) |
| Login browser | ASWebAuthenticationSession (iOS) / Chrome Custom Tab (Android) |
| Redirect scheme |
Option 2 — App Links / Universal Links (more secure, recommended):
https://app.example.com/callback?code=...&state=... Requires: HTTPS domain verification (Apple / Google) iOS: apple-app-site-association file on server Android: .well-known/assetlinks.json on server Risk: Only the verified app for that domain can handle the URL
</code>
Token Storage on Mobile
┌──────────────────────────────────────────────────────────────────────┐ │ TOKEN STORAGE STRATEGY — MOBILE │ │ │ │ Token │ Storage Location │ Survives App Kill? │ │ ─────────────────────────────────────────────────────────────────── │ │ access_token │ In-memory (ViewModel/ │ ❌ No — must refresh │ │ │ AppState) │ or re-login │ │ │ │ │ │ id_token │ In-memory (used once to │ ❌ No — not needed │ │ │ extract user identity) │ after first login │ │ │ │ │ │ refresh_token │ iOS: Keychain Services │ ✅ Yes (Keychain │ │ │ Android: EncryptedSharedPrefs / Keystore │ │ │ Flutter: flutter_secure_storage │ │ │ React Native: react-native-keychain │ └──────────────────────────────────────────────────────────────────────┘ iOS Keychain attributes for refresh_token: kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly kSecAttrService: "com.example.myapp.auth" kSecAttrAccount: "refresh_token" → Token encrypted by iOS Secure Enclave key → Not accessible if device is locked (after first boot) → Not backed up to iCloud (ThisDeviceOnly) → Not accessible on jailbroken devices (with Secure Enclave) Android EncryptedSharedPreferences: MasterKey.Builder → AES256_GCM EncryptedSharedPreferences.create(...) → Backed by Android Keystore (hardware-backed on modern devices) → setUserAuthenticationRequired(true) for biometric gate (optional) → Not exportable from device
Scenario 1: Unauthenticated User
Overview
Alice installs the mobile app and opens it for the first time. She has no tokens stored anywhere. She must authenticate and then access protected APIs on ServerA and ServerB.
Step 1 — App Starts, Checks Secure Storage
// iOS — App startup auth check func checkAuthState() { // 1. Check memory for access_token if let token = AppState.shared.accessToken, !token.isExpired { // → Scenario 2A: token valid, proceed return } // 2. Check Keychain for refresh_token if let refreshToken = Keychain.read(key: "refresh_token") { // → Scenario 2B: attempt silent refresh silentRefresh(refreshToken: refreshToken) return } // 3. No tokens anywhere → must login // → Scenario 1: initiate full OIDC login initiateLogin() }
What happens: No access_token in memory, no refresh_token in Keychain. Full login flow required.
Step 2 — App Generates PKCE Values and state
Mobile App generates (in memory, NOT persisted to Keychain):
code_verifier = base64url(SecureRandom(32 bytes))
= "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = base64url(SHA256(code_verifier))
= "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
state = base64url(SecureRandom(16 bytes))
= "xyzABC123randomstate"
All three stored in ViewModel memory for duration of login flow.
Step 3 — App Opens System Browser with /authorize URL
Platform-specific implementation:
// iOS let session = ASWebAuthenticationSession( url: authorizeURL, callbackURLScheme: "myapp" ) { callbackURL, error in guard let url = callbackURL else { return } handleCallback(url: url) } session.presentationContextProvider = self session.prefersEphemeralWebBrowserSession = false // share SSO session session.start()
// Android val customTabsIntent = CustomTabsIntent.Builder().build() customTabsIntent.launchUrl(context, Uri.parse(authorizeURL)) // App re-opened via AndroidManifest intent-filter for myapp://auth/callback
prefersEphemeralWebBrowserSession = false (iOS): allows the system browser to
share its cookie jar with Safari, enabling SSO if the user already has an active
SSO session from another app or from mobile Safari.
Step 4 — System Browser Navigates to ServerSSO
System Browser → ServerSSO:
GET https://sso.example.com/authorize
?response_type=code
&client_id=mobile-app-001
&redirect_uri=myapp://auth/callback
&scope=openid profile email api:serverA api:serverB
&state=xyzABC123randomstate
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
Cookie: (no sso_session — first login)
ServerSSO checks: no SSO session cookie → must authenticate → show login page.
Step 5 — ServerSSO Presents Login Page in System Browser
ServerSSO → System Browser:
HTTP 200 Content-Type: text/html
<html>
<body>
<form action="https://sso.example.com/login" method="POST">
<input name="username" type="text" placeholder="Email" />
<input name="password" type="password" placeholder="Password" />
<button type="submit">Sign In</button>
</form>
</body>
</html>
The mobile app process cannot see this page or the credentials Alice types. The system browser has a separate process with its own memory space.
Step 6 — Alice Submits Credentials in System Browser
System Browser → ServerSSO: POST https://sso.example.com/login Content-Type: application/x-www-form-urlencoded username=alice%40example.com&password=secret123
ServerSSO processing:
- Validates credentials against user store
- If MFA configured → challenges second factor in same browser session
- On success: creates SSO session, stores code_challenge binding
Step 7 — ServerSSO Sets SSO Cookie and Redirects with Authorization Code
ServerSSO → System Browser:
HTTP 302
Set-Cookie: sso_session=SSO-MOB-XYZ; Domain=sso.example.com;
HttpOnly; Secure; SameSite=None
Location: myapp://auth/callback
?code=AUTH-CODE-MOBILE-111
&state=xyzABC123randomstate
ServerSSO internally stores binding:
"AUTH-CODE-MOBILE-111" → {
client_id: "mobile-app-001",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
challenge_method: "S256",
redirect_uri: "myapp://auth/callback",
scope: "openid profile email api:serverA api:serverB",
user_id: "user-uid-456",
expires_at: now + 60s ← single-use, 60 second TTL
}
The SSO session cookie is set on the system browser, not on the app. This enables future SSO for other apps that also use the system browser.
Step 8 — System Browser Follows Redirect, OS Hands Control to App
System Browser attempts to navigate to: myapp://auth/callback?code=...&state=...
iOS: ASWebAuthenticationSession detects the callback scheme "myapp"
→ calls completion handler with callbackURL
→ system browser session ends automatically
Android: OS resolves intent for scheme "myapp" → Android routes to app's
Activity registered in AndroidManifest.xml for this URI scheme
→ Activity.onNewIntent(intent) fires with the callback URI
The authorization code is now inside the mobile app. It is not a token — it must be exchanged immediately (60 second window).
Step 9 — App Extracts and Validates code and state
// iOS callback handler func handleCallback(url: URL) { let components = URLComponents(url: url, resolvingAgainstBaseURL: false) let params = components?.queryItems?.reduce(into: [String:String]()) { $0[$1.name] = $1.value } ?? [:] guard let code = params["code"], let state = params["state"], state == AppState.shared.pendingState // ← CSRF check else { // State mismatch → abort → possible CSRF / redirect hijack handleAuthError("state_mismatch") return } // Retrieve PKCE verifier from ViewModel memory let codeVerifier = AppState.shared.pendingCodeVerifier AppState.shared.clearPendingAuth() // clean up // Exchange code for tokens exchangeCodeForTokens(code: code, codeVerifier: codeVerifier) }
Step 10 — App Exchanges Code for Tokens (Device → ServerSSO, HTTPS)
The mobile app calls the token endpoint directly — this is device-to-server, not proxied through any intermediate. The code_verifier proves this exchange comes from the same party that initiated the authorization request.
ServerSSO PKCE verification:
Step 11 — ServerSSO Signs and Returns JWT Tokens
🔐 PRIVATE KEY USED HERE — ServerSSO signs both JWTs with RSA private key
ServerSSO → Mobile App:
HTTP 200 Content-Type: application/json
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "rtMOB-abc-refresh-secure-token",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",
"scope": "openid profile email api:serverA api:serverB"
}
JWT header (both tokens):
{
"alg": "RS256",
"kid": "key-2024-01", ← identifies which public key to use for verification
"typ": "JWT"
}
access_token payload:
{
"iss": "https://sso.example.com",
"sub": "user-uid-456",
"aud": ["https://api-a.example.com", "https://api-b.example.com"],
"exp": 1718550000,
"iat": 1718549100,
"nbf": 1718549100,
"jti": "jwt-mobile-unique-id-xyz",
"email": "alice@example.com",
"scope": "openid profile email api:serverA api:serverB",
"roles": ["user"]
}
id_token payload:
{
"iss": "https://sso.example.com",
"sub": "user-uid-456",
"aud": "mobile-app-001", ← audience = client_id for id_token
"exp": 1718549400,
"iat": 1718549100,
"nonce": "nonce-mob-4f8c", ← if nonce was included in /authorize
"email": "alice@example.com",
"name": "Alice Martin"
}
All signatures computed with: 🔐 ServerSSO RSA PRIVATE KEY (kid: key-2024-01)
Step 12 — App Stores Tokens Securely
// iOS — token storage after successful exchange func storeTokens(tokenResponse: TokenResponse) { // access_token → memory ONLY (ViewModel / AppState) AppState.shared.accessToken = tokenResponse.accessToken AppState.shared.accessTokenExp = Date().addingTimeInterval( TimeInterval(tokenResponse.expiresIn)) // refresh_token → Keychain (persists across app kills) Keychain.write( key: "refresh_token", value: tokenResponse.refreshToken, accessibility: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly ) // id_token → memory for user info extraction, then discard let userInfo = parseIdToken(tokenResponse.idToken) AppState.shared.userEmail = userInfo.email AppState.shared.userName = userInfo.name // id_token NOT persisted — not needed after user info extracted }
// Android — token storage fun storeTokens(tokenResponse: TokenResponse) { // access_token → ViewModel memory viewModel.accessToken = tokenResponse.accessToken viewModel.accessTokenExp = System.currentTimeMillis() + (tokenResponse.expiresIn * 1000L) // refresh_token → EncryptedSharedPreferences (Android Keystore-backed) val prefs = EncryptedSharedPreferences.create( "auth_prefs", masterKey, context, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) prefs.edit().putString("refresh_token", tokenResponse.refreshToken).apply() // id_token → extract user info then discard val claims = parseIdToken(tokenResponse.idToken) viewModel.userEmail = claims.email }
Step 13 — App Calls ServerA with Bearer JWT
This is a direct device-to-server HTTPS call — no proxy, no intermediate. The JWT travels inside the TLS-encrypted HTTP request.
Step 14 — ServerA Validates JWT via JWKS (Offline)
🔓 PUBLIC KEY USED HERE — ServerA verifies JWT signature using cached RSA public key
# ServerA JWT validation middleware def validate_bearer_jwt(request): auth = request.headers.get('Authorization', ) if not auth.startswith('Bearer '): return error_response(401, 'missing_token') token = auth[7:] # 1. Decode header (no verification) to get kid header = decode_jwt_header_unverified(token) kid = header.get('kid') # 2. Look up public key in JWKS cache public_key = jwks_cache.get(kid) if not public_key: # Unknown kid → refresh JWKS from ServerSSO jwks_cache.refresh('https://sso.example.com/.well-known/jwks.json') public_key = jwks_cache.get(kid) if not public_key: return error_response(401, 'unknown_signing_key') # 3. 🔓 Verify RSA signature using public key try: payload = jwt.decode( token, public_key, algorithms=['RS256'], issuer='https://sso.example.com', audience='https://api-a.example.com', options={'require': ['exp', 'iat', 'nbf', 'iss', 'aud', 'sub']} ) except jwt.ExpiredSignatureError: return error_response(401, 'token_expired') except jwt.InvalidSignatureError: return error_response(401, 'invalid_signature') except jwt.InvalidAudienceError: return error_response(403, 'invalid_audience') # 4. Verify required scope if 'api:serverA' not in payload.get('scope', ).split(): return error_response(403, 'insufficient_scope') # 5. Inject user context — no ServerSSO contact needed request.user = { 'sub': payload['sub'], 'email': payload.get('email'), 'roles': payload.get('roles', []) } # Continue to handler
ServerSSO is NOT contacted during JWT validation. Validation is offline cryptographic verification using the cached public key.
Step 15 — ServerA Returns Protected Resource
ServerA → Mobile App:
HTTP 200 Content-Type: application/json
{
"data": [...],
"user": "alice@example.com",
"source": "ServerA"
}
Step 16 — App Calls ServerB with Same JWT
The same access_token is used for ServerB because:
- aud claim includes https://api-b.example.com
- scope includes api:serverB
🔓 PUBLIC KEY USED HERE — ServerB independently verifies the same JWT
ServerB has its own JWKS cache. Both servers share the same public key
(from the same JWKS endpoint) but operate completely independently.
Step 17 — ServerB Returns Protected Resource
ServerB → Mobile App:
HTTP 200
{ "records": [...], "source": "ServerB" }
Scenario 1 — Complete Flow Summary
Mobile App System Browser ServerSSO ServerA ServerB
│ │ │ │ │
│ check storage │ │ │ │
│ (empty) │ │ │ │
│ gen PKCE+state│ │ │ │
│ │ │ │ │
│─ open browser ▶ │ │ │
│ │─ GET /authorize ─────────────▶│ │
│ │ (no sso cookie) │ │ │
│ │◀─ login page ────│ │ │
│ │─ POST /login ────▶ │ │
│ │◀─ sso cookie + 302 myapp://────│ │
│ │ │ │ │
│◀─ callback URL (code+state) ──│ │ │
│ validate state │ │ │
│─ POST /token ─────────────────▶ │ │
│ (code + code_verifier) │ │ │
│ 🔐 SSO signs JWTs with │ │ │
│ private key │ │ │
│◀─ {access_token(JWT), │ │ │
│ refresh_token, id_token} ──│ │ │
│ store: access→memory, │ │ │
│ refresh→Keychain │ │ │
│ │ │ │ │
│─ GET /api ────────────────────────────────────▶ │
│ Bearer JWT │ │ 🔓 verify JWT │
│ │ │ (public key) │ │
│◀─ 200 {data} ─────────────────────────────────│ │
│ │ │ │ │
│─ GET /api ────────────────────────────────────────────────▶│
│ Bearer JWT │ │ │ 🔓 verify JWT│
│ │ │ │ (public key) │
│◀─ 200 {data} ──────────────────────────────────────────────│
Scenario 2: Authenticated User
Overview
Alice returns to the app. This covers four sub-cases:
- 2A — App in foreground; access_token in memory and still valid
- 2B — App was killed and relaunched; refresh_token in Keychain; silent refresh
- 2C — App in foreground; access_token expired; silent background refresh
- 2D — Refresh token expired; seamless re-auth via SSO session in system browser
Scenario 2A — App Foreground, Access Token Valid
func getValidToken() -> String? { guard let token = AppState.shared.accessToken, AppState.shared.accessTokenExp > Date().addingTimeInterval(60) // 60s buffer else { return nil } return token } // Token valid → call APIs directly, no SSO interaction if let token = getValidToken() { callServerA(bearerToken: token) callServerB(bearerToken: token) }
Mobile App System Browser ServerSSO ServerA ServerB
│ │ │ │ │
│ access_token │ │ │ │
│ in memory ✅ │ │ │ │
│ │ │ │ │
│─ GET /api ────────────────────────────────────▶ │
│ Bearer JWT │ │ 🔓 verify JWT │
│◀─ 200 {data} ─────────────────────────────────│ │
│ │ │ │ │
│─ GET /api ─────────────────────────────────────────────────▶│
│ Bearer JWT │ │ │ 🔓 verify JWT│
│◀─ 200 {data} ──────────────────────────────────────────────│
Zero SSO contact. ServerA and ServerB verify JWTs offline.
Scenario 2B — App Killed and Relaunched (Refresh from Keychain)
When the OS kills the app (memory pressure, user swipe-close), in-memory tokens are lost. The refresh_token survives in the Keychain.
Step 1 — App Launches, Finds No In-Memory Token
func appDidLaunch() { // Memory is empty — app was killed AppState.shared.accessToken = nil // Check Keychain for refresh_token if let refreshToken = Keychain.read(key: "refresh_token") { silentRefresh(refreshToken: refreshToken) } else { // No refresh token → full login (Scenario 1) initiateLogin() } }
Step 2 — App Calls ServerSSO /token with Refresh Token (Direct HTTPS)
Mobile App → ServerSSO:
POST https://sso.example.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=rtMOB-abc-refresh-secure-token
&client_id=mobile-app-001
Note: NO client_secret — public client.
No PKCE needed for refresh — refresh_token itself is the credential.
Step 3 — ServerSSO Validates Refresh Token
ServerSSO checks: - refresh_token is valid and not revoked - refresh_token is not expired (24h TTL) - SSO session associated with this token is still active - client_id matches the one the token was issued to If SSO session was terminated (e.g. password changed, global logout): → Returns error: invalid_grant → Mobile app must initiate full login (Scenario 1)
Step 4 — ServerSSO Returns New Tokens
🔐 PRIVATE KEY USED HERE — ServerSSO signs fresh JWTs with RSA private key
ServerSSO → Mobile App:
HTTP 200
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...(new)",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "rtMOB-xyz-new-rotated-token", ← old refresh_token invalidated
"scope": "openid profile email api:serverA api:serverB"
}
Signature: 🔐 ServerSSO RSA PRIVATE KEY
Step 5 — App Updates Storage
func handleRefreshSuccess(tokenResponse: TokenResponse) { // New access_token → memory AppState.shared.accessToken = tokenResponse.accessToken AppState.shared.accessTokenExp = Date().addingTimeInterval( TimeInterval(tokenResponse.expiresIn)) // New refresh_token → Keychain (overwrites old — rotation) Keychain.write(key: "refresh_token", value: tokenResponse.refreshToken, accessibility: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) }
Step 6 — App Continues with API Calls
Mobile App System Browser ServerSSO ServerA ServerB
│ │ │ │ │
│ app relaunched│ │ │ │
│ memory empty │ │ │ │
│ read Keychain ✅ │ │ │
│─ POST /token ────────────────▶ │ │
│ (refresh_token) │ │ │
│ 🔐 SSO signs new JWT │ │ │
│◀─ new {access_token, ...} ────│ │ │
│ update memory + Keychain │ │ │
│ │ │ │ │
│─ GET /api ────────────────────────────────────▶ │
│ new Bearer JWT │ 🔓 verify JWT │
│◀─ 200 {data} ─────────────────────────────────│ │
Scenario 2C — App Foreground, Access Token Expired
The app is running in the foreground but the 15-minute access_token has expired. This is handled transparently, typically by an HTTP interceptor.
// iOS — Alamofire/URLSession interceptor pattern class TokenRefreshInterceptor: RequestInterceptor { func adapt(_ request: URLRequest, ...) async throws -> URLRequest { var req = request let token = await getOrRefreshToken() // refresh if needed req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") return req } func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping ...) { if case .responseValidationFailed(let reason) = error, case .unacceptableStatusCode(let code) = reason, code == 401 { // Token rejected → refresh and retry once Task { await silentRefresh() completion(.retry) } } else { completion(.doNotRetry) } } }
Mobile App System Browser ServerSSO ServerA
│ │ │ │
│─ GET /api ────────────────────────────────────▶
│ expired JWT │ │ 🔓 verify: exp < now
│◀─ 401 ────────────────────────────────────────│
│ interceptor fires │ │
│─ POST /token ────────────────▶ │
│ (refresh_token, s background)│ │
│ 🔐 SSO signs new JWT │ │
│◀─ new tokens ─────────────────│ │
│─ GET /api (retry) ────────────────────────────▶
│ new Bearer JWT │ 🔓 verify JWT ✅
│◀─ 200 {data} ─────────────────────────────────│
The user experiences no interruption — the retry is transparent.
Scenario 2D — Refresh Token Expired, Re-auth via SSO Session
The refresh_token has a 24-hour TTL. After 24 hours without app use, it expires. The app must re-authenticate — but if the user has an active SSO session in the system browser (e.g., from using another app earlier that day), the login is seamless.
Step 1 — Refresh Fails: invalid_grant
ServerSSO → Mobile App:
HTTP 400
{ "error": "invalid_grant", "error_description": "Refresh token expired or revoked" }
Step 2 — App Clears Stale Tokens and Initiates Login
func handleRefreshFailure(error: OAuthError) { // Clear stale tokens AppState.shared.accessToken = nil Keychain.delete(key: "refresh_token") // Initiate full login flow // BUT: system browser MAY have active SSO session from: // - Another app that uses the same SSO // - Mobile Safari where user was previously logged in initiateLogin() }
Step 3 — Full Login Flow Initiated
Same as Scenario 1 Steps 2–10, but with a key difference:
Mobile App → System Browser: open /authorize (new PKCE + state) System Browser → ServerSSO: GET /authorize Cookie: sso_session=SSO-MOB-XYZ ← SSO session still active in browser! ServerSSO: SSO session found and valid → Skip login page entirely → Issue new authorization code immediately → Redirect back to myapp://auth/callback?code=...
Alice taps the app, the system browser flashes briefly and returns immediately. She never sees a login form. This is the SSO effect on mobile.
Step 4 — App Receives New Code and Completes Token Exchange
Normal PKCE token exchange (Steps 9–12 from Scenario 1). New access_token and refresh_token issued and stored.
Mobile App System Browser ServerSSO ServerA
│ │ │ │
│ refresh expired │ │
│ clear Keychain│ │ │
│ gen PKCE+state│ │ │
│─ open browser ▶ │ │
│ │─ GET /authorize ─────────────▶
│ │ Cookie: sso_session ✅ │
│ │ SSO session valid → skip login│
│ │◀─ 302 myapp://callback?code ──│
│◀─ callback ───│ │ │
│ validate state │ │
│─ POST /token ─────────────────▶ │
│ (code + code_verifier) │ │
│ 🔐 SSO signs new JWTs │ │
│◀─ new tokens ─────────────────│ │
│ store: memory + Keychain │ │
│─ GET /api ────────────────────────────────────▶
│ new Bearer JWT │ 🔓 verify JWT │
│◀─ 200 {data} ─────────────────────────────────│
Sequence Diagrams
Full PKCE Flow — Mobile (Scenario 1, all steps)
1. Mobile App → Mobile App : check memory → no access_token
2. Mobile App → Keychain : read refresh_token → empty (first login)
3. Mobile App → Mobile App : generate code_verifier, code_challenge (S256), state
4. Mobile App → System Browser: open ASWebAuthSession / Chrome Custom Tab
5. Sys Browser → ServerSSO : GET /authorize?client_id=mobile-app-001&code_challenge=...
6. ServerSSO → Sys Browser : 200 login page (no sso_session cookie)
7. Alice → Sys Browser : type credentials
8. Sys Browser → ServerSSO : POST /login (username + password)
9. ServerSSO → ServerSSO : validate credentials; create SSO session; bind code→challenge
10. ServerSSO → Sys Browser : Set-Cookie: sso_session + 302 myapp://callback?code=CODE
11. Sys Browser → Mobile App : deliver callback URL (ASWebAuthSession completion / Intent)
12. Mobile App → Mobile App : validate state == pending_state (CSRF check)
13. Mobile App → Mobile App : retrieve code_verifier from ViewModel memory
14. Mobile App → ServerSSO : POST /token (code=CODE, code_verifier=VERIFIER, client_id)
15. ServerSSO → ServerSSO : verify SHA256(code_verifier) == stored code_challenge ✅
16. ServerSSO → ServerSSO : 🔐 SIGN access_token JWT with RSA PRIVATE KEY
17. ServerSSO → ServerSSO : 🔐 SIGN id_token JWT with RSA PRIVATE KEY
18. ServerSSO → Mobile App : { access_token, id_token, refresh_token, expires_in }
19. Mobile App → Mobile App : store access_token → memory
20. Mobile App → Keychain : store refresh_token → secure storage
21. Mobile App → ServerA : GET /api Authorization: Bearer access_token
22. ServerA → JWKS Cache : lookup public key for kid="key-2024-01"
(if not cached: GET /sso.example.com/.well-known/jwks.json)
23. ServerA → ServerA : 🔓 VERIFY JWT signature with RSA PUBLIC KEY
24. ServerA → ServerA : validate iss, aud, exp, scope = api:serverA
25. ServerA → Mobile App : 200 { data }
26. Mobile App → ServerB : GET /api Authorization: Bearer access_token (same token)
27. ServerB → JWKS Cache : lookup public key (own cache, may already have it)
28. ServerB → ServerB : 🔓 VERIFY JWT signature with RSA PUBLIC KEY
29. ServerB → ServerB : validate iss, aud, exp, scope = api:serverB
30. ServerB → Mobile App : 200 { data }
KEY USAGE SUMMARY:
Steps 16–17 → 🔐 ServerSSO RSA PRIVATE KEY (signing)
Steps 23, 28 → 🔓 RSA PUBLIC KEY from JWKS (verification by ServerA / ServerB)
API Contracts
ServerSSO Endpoints
GET /.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",
"response_types_supported": ["code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid","profile","email","api:serverA","api:serverB"],
"token_endpoint_auth_methods_supported": ["none"],
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["authorization_code", "refresh_token"]
}
GET /authorize — Parameters
| Parameter | Required | Value / Example | Notes |
|---|---|---|---|
| response_type | ✅ | code | Always code |
| client_id | ✅ | mobile-app-001 | Registered mobile client |
| redirect_uri | ✅ |
EVIL-APP calls POST /token with stolen code: grant_type=authorization_code &code=STOLEN-CODE &client_id=mobile-app-001 ← attacker knows this (it's public) → ServerSSO issues tokens ← ATTACK SUCCEEDS ❌
With PKCE (S256):
EVIL-APP intercepts code but does NOT have code_verifier (in legitimate app memory): POST /token with STOLEN-CODE but WRONG code_verifier → ServerSSO: SHA256(wrong_verifier) ≠ stored code_challenge → Error: invalid_grant ← ATTACK BLOCKED ✅
The code_verifier never traverses the redirect channel. It stays in the legitimate app's memory and is only sent directly to the token endpoint over TLS.
</code>
Security Checklist
| # | Control | Description |
|---|---|---|
| 1 | PKCE S256 enforced | Server rejects requests without code_challenge or with plain |
| 2 | System browser only | No WebView for authentication (RFC 8252) |
| 3 | state validation | Validate state == pending_state on every callback |
| 4 | Redirect URI exact match | SSO refuses partial/wildcard redirect URIs |
| 5 | access_token in memory only | Never persist to disk; lost on app kill is acceptable |
| 6 | refresh_token in secure storage | Keychain (iOS) / Keystore (Android); ThisDeviceOnly |
| 7 | Short access_token TTL | 15 minutes maximum |
| 8 | Refresh token rotation | New token per use; old token invalidated server-side |
| 9 | Audience validation | ServerA checks aud; ServerB checks aud independently |
| 10 | Scope validation | Each server checks required scope in JWT |
| 11 | JWKS caching with TTL | 1 hour; refetch only on unknown kid |
| 12 | Certificate pinning (optional) | Pin SSO and API server certificates for high-security apps |
| 13 | Biometric gate for refresh_token | Require biometric before reading Keychain (optional, high-risk) |
| 14 | Jailbreak detection | Warn or block on rooted/jailbroken devices |
| 15 | No noneOf algorithm | Server rejects JWTs with alg: none |
Configuration Reference
ServerSSO — Mobile Client Registration
clients: - client_id: "mobile-app-001" client_type: public # No client_secret display_name: "My Mobile App" redirect_uris: - "myapp://auth/callback" # Custom URI scheme - "https://app.example.com/callback" # Universal Link / App Link (preferred) post_logout_redirect_uris: - "myapp://auth/logout" allowed_scopes: - openid - profile - email - api:serverA - api:serverB token_endpoint_auth_method: none # Public client — no secret code_challenge_method: S256 # PKCE required; plain rejected pkce_required: true access_token_ttl: 900 # 15 minutes refresh_token_ttl: 86400 # 24 hours id_token_ttl: 300 # 5 minutes auth_code_ttl: 60 # 60 seconds (single use)
ServerA JWT Validation
jwt: issuer: "https://sso.example.com" audience: "https://api-a.example.com" algorithms: [RS256] jwks_uri: "https://sso.example.com/.well-known/jwks.json" jwks_cache_ttl: 3600 required_scope: "api:serverA" clock_skew: 30 reject_alg_none: true
ServerB JWT Validation
jwt: issuer: "https://sso.example.com" audience: "https://api-b.example.com" algorithms: [RS256] jwks_uri: "https://sso.example.com/.well-known/jwks.json" jwks_cache_ttl: 3600 required_scope: "api:serverB" clock_skew: 30 reject_alg_none: true
Mobile App — iOS Configuration
// OIDCConfig.swift struct OIDCConfig { static let issuer = URL(string: "https://sso.example.com")! static let clientID = "mobile-app-001" static let redirectURI = URL(string: "myapp://auth/callback")! static let scopes = ["openid", "profile", "email", "api:serverA", "api:serverB"] static let serverABaseURL = "https://api-a.example.com" static let serverBBaseURL = "https://api-b.example.com" // Keychain static let keychainService = "com.example.myapp.auth" static let refreshTokenKey = "refresh_token" static let keychainAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly }
Mobile App — Android Configuration
// AuthConfig.kt object AuthConfig { const val ISSUER = "https://sso.example.com" const val CLIENT_ID = "mobile-app-001" const val REDIRECT_URI = "myapp://auth/callback" val SCOPES = listOf("openid", "profile", "email", "api:serverA", "api:serverB") const val SERVER_A_BASE_URL = "https://api-a.example.com" const val SERVER_B_BASE_URL = "https://api-b.example.com" // EncryptedSharedPreferences key const val PREFS_FILE = "auth_prefs" const val REFRESH_TOKEN_KEY = "refresh_token" }
Quick Reference Card
╔══════════════════════════════════════════════════════════════════════════╗ ║ OIDC MOBILE APP + PKCE — QUICK REFERENCE ║ ╠══════════════════════════════════════════════════════════════════════════╣ ║ RSA KEY USAGE ║ ║ 🔐 PRIVATE KEY → ServerSSO ONLY ║ ║ Signs: access_token, id_token ║ ║ Location: HSM / KMS — never leaves ServerSSO ║ ║ Steps: Scenario 1 Step 11 (sign), Scenario 2B Step 4 (sign) ║ ║ ║ ║ 🔓 PUBLIC KEY → ServerA + ServerB (fetched from JWKS, cached 1hr) ║ ║ Verifies: every incoming Bearer JWT ║ ║ Fetched: GET /sso.example.com/.well-known/jwks.json ║ ║ Steps: Scenario 1 Step 14 (ServerA), Step 16 (ServerB) ║ ║ ║ ║ Mobile App: holds NO RSA key — forwards JWT as Bearer token only ║ ╠══════════════════════════════════════════════════════════════════════════╣ ║ PKCE ║ ║ code_verifier = base64url(SecureRandom(32 bytes)) ← memory only ║ ║ code_challenge = base64url(SHA256(code_verifier)) ← sent to SSO ║ ║ method = S256 (plain rejected) ║ ║ Verifier sent to /token endpoint — never in redirect URI ║ ╠══════════════════════════════════════════════════════════════════════════╣ ║ CLIENT TYPE: PUBLIC — no client_secret ║ ║ PKCE replaces client_secret for public clients ║ ║ /token call: grant_type + code + code_verifier + client_id only ║ ╠══════════════════════════════════════════════════════════════════════════╣ ║ TOKEN STORAGE ║ ║ access_token → Memory ONLY (ViewModel / AppState) ║ ║ refresh_token → iOS Keychain / Android EncryptedSharedPrefs ║ ║ id_token → Memory (extract user info once then discard) ║ ║ Browser holds → sso_session cookie (system browser, not app) ║ ╠══════════════════════════════════════════════════════════════════════════╣ ║ SYSTEM BROWSER (RFC 8252) ║ ║ iOS: ASWebAuthenticationSession ║ ║ Android: Chrome Custom Tab ║ ║ Shares SSO session with other apps using same system browser ║ ║ App cannot read credentials — process isolation ║ ╠══════════════════════════════════════════════════════════════════════════╣ ║ SCENARIO DECISION TREE ║ ║ access_token in memory + not expired → 2A: use directly ║ ║ access_token expired + refresh in Keychain → 2B/2C: silent refresh ║ ║ refresh_token expired / invalid_grant → 2D: re-auth via system browser║ ║ No tokens anywhere → Scenario 1: full login ║ ╠══════════════════════════════════════════════════════════════════════════╣ ║ TOKEN LIFETIMES ║ ║ access_token 15 min (memory; lost on app kill → refresh) ║ ║ id_token 5 min (memory; discard after user info extracted) ║ ║ refresh_token 24 hrs (Keychain; rotated on each use) ║ ║ auth code 60 sec (single use; PKCE-bound) ║ ║ SSO session 8 hrs (system browser cookie store) ║ ╠══════════════════════════════════════════════════════════════════════════╣ ║ KEY SECURITY PROPERTIES ║ ║ Stolen auth code → useless (PKCE: no verifier = no tokens) ║ ║ Stolen access JWT → expires in 15 min; no refresh issued with it ║ ║ Stolen refresh → rotation detects reuse; device binding optional ║ ║ Forged JWT → impossible without RSA private key (ServerSSO) ║ ║ No client_secret → none to steal from binary / decompilation ║ ╚══════════════════════════════════════════════════════════════════════════╝
Document maintained by: Platform Security Team Format: DokuWiki Standard: OpenID Connect Core 1.0 · RFC 8252 (OAuth 2.0 for Native Apps) · RFC 7636 (PKCE) · RFC 7519 (JWT) · RFC 7517 (JWK)
