User Tools

Site Tools


security:sso-mobile

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:

  1. Receives the redirect via the OS URL scheme handler
  2. Validates that ``state`` matches what was stored in Step 1 (CSRF check)
  3. 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:

  1. Verify RS256 signature using the public key matching ``kid``
  2. 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:

  1. Refresh token exists in its database and is not revoked
  2. Refresh token has not expired (typically 30 days for mobile apps)
  3. ``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:

  1. New JWTs are signed with the new key (new ``kid``)
  2. Old JWTs remain valid until they expire (old key stays in JWKS temporarily)
  3. ServerA/ServerB fetch the new JWKS when they encounter an unknown ``kid``
  4. 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
security/sso-mobile.txt · Last modified: by phong2018