| name | mobile-auth-backend |
| description | Server-side OAuth 2.1 + PKCE for native mobile apps -- authorization endpoint, token endpoint, refresh rotation, and device binding. Use when implementing or reviewing the auth server for mobile clients. |
Mobile Auth Backend (OAuth 2.1 + PKCE)
Instructions
Native mobile apps cannot hold a confidential client secret. The only defensible interactive-login flow is OAuth 2.1 Authorization Code with PKCE, using the system browser (ASWebAuthenticationSession on iOS, Custom Tabs on Android). No embedded WebView. No resource-owner password grant.
1. Flow Overview
App generates code_verifier (random 43-128 bytes) and code_challenge = BASE64URL(SHA256(code_verifier))
App -> /authorize?response_type=code
&client_id=mobile
&redirect_uri=com.example.app:/oauth/callback
&code_challenge=<b64url>
&code_challenge_method=S256
&state=<random>
&scope=openid profile offline_access
User signs in in system browser -> redirect back with ?code=... &state=...
App -> POST /token (code, code_verifier, redirect_uri, client_id)
-> { access_token, refresh_token, expires_in, id_token? }
2. Authorization Endpoint
Register each mobile client with:
client_id (public, no secret).
- Allowed redirect URIs: universal links (
https://app.example.com/oauth/callback) are preferred over custom schemes where possible. Custom schemes (com.example.app:/oauth/callback) are allowed.
- Allowed scopes and response types (
code only).
- PKCE required; reject
code_challenge_method=plain.
Validate redirect_uri with exact string match. Never prefix-match.
3. Token Endpoint
app.post("/oauth/token", async (req, res) => {
const { grant_type, code, code_verifier, redirect_uri, client_id, refresh_token } = req.body;
if (grant_type === "authorization_code") {
const entry = await codeStore.consume(code);
if (!entry || entry.clientId !== client_id || entry.redirectUri !== redirect_uri) {
return res.status(400).json({ error: "invalid_grant" });
}
const challenge = base64url(sha256(code_verifier));
if (challenge !== entry.codeChallenge) {
return res.status(400).json({ error: "invalid_grant" });
}
return res.json(await issueTokens(entry.userId, client_id, entry.deviceId));
}
if (grant_type === "refresh_token") {
return res.json(await rotateRefresh(refresh_token, client_id));
}
return res.status(400).json({ error: "unsupported_grant_type" });
});
Access tokens are short-lived (5–15 minutes). Refresh tokens are long-lived but rotated on every use (see next section).
4. Refresh Rotation and Replay Detection
Every refresh request returns a new refresh token and invalidates the previous one. Store refresh tokens hashed (bcrypt/argon2 is overkill here; HMAC-SHA256 with a server-side pepper is standard) and track a session family id.
session_family_id -> (user_id, device_id, created_at)
refresh_token_hash -> (session_family_id, issued_at, rotated_at?, revoked?)
On refresh:
- Look up the token by hash.
- If already rotated or revoked -> treat as replay attack: revoke the entire
session_family_id and return invalid_grant. Alert.
- Otherwise, mark the old token rotated, issue a new pair bound to the same family.
This is RFC 6749 / OAuth 2.1 "refresh token rotation with reuse detection".
5. Device Binding
Each login creates a device_id (stable random on first launch, stored in Keychain / EncryptedSharedPreferences). Bind the session family to that device. Where the platform supports it, strengthen with:
- iOS: DeviceCheck / App Attest assertion embedded in the token request.
- Android: Play Integrity API verdict.
Reject token exchanges whose attestation does not match the registered device.
6. Access Tokens
Prefer short-lived JWTs signed with RS256 / EdDSA so resource services can verify without calling the auth server. Claims:
{
"iss": "https://auth.example.com",
"sub": "user_01HX7...",
"aud": "api",
"exp": 1713523200,
"iat": 1713522600,
"scope": "profile feed",
"device_id": "dev_01HX7...",
"sid": "sess_01HX7..."
}
Publish JWKS at /.well-known/jwks.json and rotate keys every 90 days with overlap.
7. Logout and Revocation
POST /oauth/revoke with a refresh token: revoke the session family.
POST /oauth/logout invalidates the current session family and returns 204.
- Access tokens are not individually revocable; short expiry is the mitigation. For sensitive actions (password change, device remove), publish to a revocation list (short-lived Redis set) that resource servers check.
8. Rate Limiting and Abuse
- Rate-limit
/token per client_id + IP and per device_id.
- Lockout
/authorize credential checks per account and per IP with exponential backoff.
- Log all failed refresh rotations; repeated failures trigger session-family revocation.
Checklist