OAuth 2.0, OpenID Connect, JWT, JWKS, PKCE, and modern auth patterns
Security / AuthOAuth 2.0 is an authorization framework that lets third-party apps access a user's resources without exposing their credentials. It delegates authentication to the service that hosts the user account.
| Role | Description | Example |
|---|---|---|
| Resource Owner | The user who owns the data | You (the end user) |
| Client | The application requesting access | Your web/mobile app |
| Authorization Server | Issues tokens after authentication | Google, Auth0, Keycloak |
| Resource Server | Hosts the protected resources (API) | Google Drive API |
The most secure and commonly used flow for server-side web apps. The client never sees the user's credentials.
state parameter prevents CSRF attacks.
// Server-side token exchange
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
client_id: 'abc123',
client_secret: process.env.CLIENT_SECRET,
redirect_uri: 'https://app.com/callback'
})
});
const { access_token, refresh_token, id_token } = await response.json();PKCE (pronounced "pixy") protects the authorization code flow for public clients (SPAs, mobile apps) that cannot securely store a client secret.
| Step | Action | Detail |
|---|---|---|
| 1 | Generate code_verifier | Random string, 43β128 chars (A-Z, a-z, 0-9, -, ., _, ~) |
| 2 | Create code_challenge | BASE64URL(SHA256(code_verifier)) |
| 3 | Send challenge in /authorize | code_challenge=...&code_challenge_method=S256 |
| 4 | Send verifier in /token | code_verifier=ORIGINAL_STRING |
| 5 | Server verifies | Server hashes verifier and compares with stored challenge |
// Generate PKCE code_verifier and code_challenge
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// Usage in authorization URL
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
// Store verifier in sessionStorage for later use
const authUrl = `https://auth.example.com/authorize?
response_type=code&
client_id=abc123&
code_challenge=${challenge}&
code_challenge_method=S256&
redirect_uri=https://app.com/callback`;plain method sends the verifier as the challenge (no hashing) β only use S256 in production.
Used for machine-to-machine (M2M) communication where no user is involved β the app authenticates as itself.
// Client Credentials β server-to-server auth
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)}`
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'api:read api:write'
})
});| Aspect | Implicit | Auth Code + PKCE |
|---|---|---|
| Token delivery | URL fragment (#access_token=...) | Server-side exchange |
| Refresh tokens | Not supported | Supported |
| Security | Vulnerable to interception | Protected by PKCE challenge |
| Status | Deprecated | Recommended |
For devices with limited input (TVs, CLI tools, IoT). User authorizes on a separate device.
| Property | Access Token | Refresh Token |
|---|---|---|
| Purpose | Authorize API requests | Get new access tokens |
| Lifetime | Short (5β60 min) | Long (daysβmonths) |
| Sent to | Resource Server (API) | Authorization Server only |
| Format | JWT or opaque string | Usually opaque |
| Storage | Memory (SPA) or cookie | Secure httpOnly cookie |
| Revocable | Not easily (until expiry) | Yes, via revocation endpoint |
// Refresh an expired access token
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: storedRefreshToken,
client_id: 'abc123'
})
});
// Returns new { access_token, refresh_token (rotated) }A compact, URL-safe token format for securely transmitting claims between parties. Consists of three Base64URL-encoded parts separated by dots.
| Algorithm | Type | Key | Use Case |
|---|---|---|---|
HS256 | Symmetric (HMAC) | Shared secret | Single service, internal APIs |
RS256 | Asymmetric (RSA) | Public/private key pair | Distributed systems, OIDC providers |
ES256 | Asymmetric (ECDSA) | Elliptic curve keys | Performance-critical, mobile |
EdDSA | Asymmetric (Ed25519) | Edwards curve keys | Modern high-performance signing |
alg: "none": Disabling signature verification is a critical vulnerability. Always validate the alg header against an allowlist.
Claims are key-value pairs in the JWT payload. They carry information about the user, token, and authorization.
| Claim | Full Name | Description | Example |
|---|---|---|---|
iss | Issuer | Who issued this token | "https://auth.example.com" |
sub | Subject | Unique identifier for the user | "user_abc123" |
aud | Audience | Intended recipient(s) of the token | "https://api.example.com" |
exp | Expiration | Epoch time when token expires | 1716242622 |
nbf | Not Before | Token not valid before this time | 1716239022 |
iat | Issued At | When the token was created | 1716239022 |
jti | JWT ID | Unique token identifier (prevents replay) | "a1b2c3d4" |
{
"iss": "https://auth.example.com",
"sub": "user_abc123",
"aud": "https://api.example.com",
"exp": 1716242622,
"iat": 1716239022,
"scope": "openid profile email",
"roles": ["admin", "editor"],
"org_id": "org_xyz",
"email": "john@example.com",
"email_verified": true
}exp β reject expired tokensnbf β reject tokens used too earlyiss β must match your expected issueraud β must include your API's identifieralg β must be in your allowlist (never accept "none")A JWKS is a JSON document containing the public keys used to verify JWT signatures. Published at a well-known URL by the authorization server.
// Typically at: https://auth.example.com/.well-known/jwks.json
{
"keys": [
{
"kty": "RSA", // Key type
"kid": "key-2024-01", // Key ID β matches JWT header 'kid'
"use": "sig", // Usage: signing
"alg": "RS256", // Algorithm
"n": "0vx7agoebGc...", // RSA modulus (Base64URL)
"e": "AQAB" // RSA exponent
}
]
}kid in the JWT header identifies which key was usedkid cache missOIDC is an identity layer on top of OAuth 2.0. While OAuth handles authorization, OIDC adds standardized authentication β telling your app who the user is.
| Feature | OAuth 2.0 | OIDC (OAuth 2.0 + Identity) |
|---|---|---|
| Purpose | Authorization (access to resources) | Authentication (user identity) |
| ID Token | Not defined | JWT with user claims |
| UserInfo endpoint | Not standardized | /userinfo |
| Discovery | Not standardized | /.well-known/openid-configuration |
| Standard scopes | App-defined | openid, profile, email, address, phone |
// ID token payload (returned when scope includes "openid")
{
"iss": "https://auth.example.com",
"sub": "user_abc123",
"aud": "client_abc123", // Your app's client_id
"exp": 1716242622,
"iat": 1716239022,
"nonce": "n-0S6_WzA2Mj", // Prevents replay attacks
"auth_time": 1716239020, // When user actually authenticated
"name": "John Doe",
"email": "john@example.com",
"email_verified": true,
"picture": "https://example.com/john.jpg"
}Every OIDC provider publishes a discovery document at /.well-known/openid-configuration:
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"userinfo_endpoint": "https://auth.example.com/userinfo",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"scopes_supported": ["openid", "profile", "email"],
"response_types_supported": ["code"],
"id_token_signing_alg_values_supported": ["RS256"]
}Scopes define what access the client is requesting. The user sees a consent screen listing these permissions.
| Scope | Claims Returned |
|---|---|
openid | sub (required for OIDC β triggers ID token) |
profile | name, family_name, given_name, picture, locale |
email | email, email_verified |
address | address (structured JSON) |
phone | phone_number, phone_number_verified |
offline_access | Grants a refresh token |
// Common patterns for API scopes
"scope": "read:users write:users delete:users" // Resource-based
"scope": "api.read api.write api.admin" // Namespace-based
"scope": "repos:read gists:write orgs:admin" // GitHub-styleAllows resource servers to check if an opaque token is valid and get its metadata.
// POST to introspection endpoint
POST /introspect
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
token=ACCESS_TOKEN&token_type_hint=access_token
// Response
{
"active": true,
"sub": "user_abc123",
"client_id": "my_app",
"scope": "read write",
"exp": 1716242622,
"token_type": "Bearer"
}// Revoke a token (usually refresh token on logout)
POST /revoke
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
token=REFRESH_TOKEN&token_type_hint=refresh_token
// Returns 200 OK (always, even if token was already invalid)| Storage | XSS Risk | CSRF Risk | Recommendation |
|---|---|---|---|
localStorage | Vulnerable | Safe | Avoid for sensitive tokens |
sessionStorage | Vulnerable | Safe | OK for short-lived tokens |
httpOnly cookie | Safe | Vulnerable | Best β add CSRF protection |
| In-memory variable | Safe | Safe | Best for SPAs (lost on refresh) |
iss, aud, exp on every JWTalg β reject "none"| Attack | Description | Prevention |
|---|---|---|
| CSRF | Attacker tricks user into authorizing a malicious request | state parameter |
| Token Leakage | Token exposed in URL, logs, or referrer header | Use auth code flow, not implicit |
| Code Interception | Auth code stolen in transit (mobile deep links) | PKCE |
| Replay Attack | Stolen token reused | nonce, jti, short expiry |
| alg:none | Forged JWT with disabled signature | Validate alg against allowlist |
| SSRF via redirect_uri | Redirect to attacker-controlled URL | Exact URI matching, pre-registered URIs |
| Scenario | Recommended Flow | Why |
|---|---|---|
| Server-side web app | Authorization Code | Client secret stored securely on server |
| SPA (React, Vue, etc.) | Auth Code + PKCE | No client secret; PKCE protects the flow |
| Mobile app | Auth Code + PKCE | Same as SPA; use custom URL scheme for redirect |
| CLI / device | Device Authorization | No browser; user authorizes on separate device |
| Server-to-server (M2M) | Client Credentials | No user involved; app authenticates as itself |
| Need user identity | OIDC (any flow + openid scope) | Returns ID token with user claims |
| Change | OAuth 2.0 | OAuth 2.1 (Draft) |
|---|---|---|
| PKCE | Optional | Required for all clients |
| Implicit flow | Allowed | Removed |
| Password grant | Allowed | Removed |
| Redirect URI | Loose matching | Exact string matching required |
| Refresh tokens | No rotation required | Rotation recommended |