// authorization framework · flows · tokens · scopes · OIDC · zero trust · senior → principal
code_challenge (PKCE). The user authenticates and consents. The AS redirects back with a short-lived code. The client exchanges the code + code_verifier for tokens at the token endpoint. The authorization code is single-use and expires in seconds — even if intercepted, it's useless without the verifier. PKCE (Proof Key for Code Exchange) is required for public clients and recommended for all clients.
client_id and client_secret (or a signed JWT for stronger auth) and receives an access token. Used for service-to-service calls, background jobs, and daemons. There is no refresh token — the client simply re-authenticates when the token expires. Scope design matters: each service should request only the scopes it needs, not a broad admin scope.
read:orders, write:profile, openid, email. The AS presents a consent screen to the user listing the requested scopes. The issued access token contains only the scopes the user approved. The resource server enforces scopes on every request — a token with read:orders must be rejected by the write endpoint. Design scopes around resources and actions, not roles. Coarse scopes (admin) create over-permissioning; too-fine scopes (read:order:12345) create consent fatigue and management overhead.
sub, email, name, iat, exp, iss, aud), a UserInfo endpoint (fetch additional claims with an access token), and a discovery document (/.well-known/openid-configuration) listing all endpoints and supported features. Use OIDC when you need to know who the user is. Use plain OAuth2 when you only need to know what they can access. Never use an access token as proof of identity — use the ID token.
/.well-known/jwks.json, verify the token signature, check iss, aud, exp, and required scopes. Fast — no network call per request. Works only for JWT tokens. Token introspection (RFC 7662): the RS calls the AS's introspection endpoint with the token; the AS responds with active: true/false and token metadata. Works for opaque tokens. Slower — one AS call per request (cache the result). Enables real-time revocation.
sub claim for identity, not the access token's presence.
state parameter in the authorization request must be a random, unguessable value bound to the user's session. On callback, the client must verify the returned state matches. Without this, an attacker can trick a victim's browser into completing an authorization flow the attacker initiated, binding the victim's session to the attacker's account (OAuth CSRF). This is a real attack — not theoretical.
localStorage. Access tokens stored there are trivially stolen via XSS. For SPAs: use the BFF (Backend for Frontend) pattern — the backend handles the OAuth2 flow and stores tokens server-side, issuing a session cookie (httpOnly, Secure, SameSite=Strict) to the browser. No tokens in the browser at all.
admin or * scopes because it's convenient means a compromised token has unlimited access. Users also see and remember broad consent screens — "this app wants access to everything." Design scopes to match actual needs: read:invoices not billing:admin. Review and prune scopes granted to long-lived integrations regularly.
aud claim in a JWT specifies which resource server the token is intended for. If a RS doesn't validate aud, a token issued for service A can be replayed against service B. This is a common misconfiguration in internal microservice architectures where all services share a single AS. Validate aud on every token, every request.
| Access Token | Short-lived (5–60 min). Presented to resource server. JWT (local validation) or opaque (introspection). |
| Refresh Token | Long-lived (hours–days). Opaque. Used to obtain new access tokens. Never sent to resource server. Rotate on every use. |
| ID Token | OIDC only. JWT. Contains user identity claims (sub, email, name). Intended for the client, not the resource server. |
| Authorization Code | Single-use. Expires in seconds. Exchanged for tokens at the token endpoint. Useless without PKCE code_verifier. |
| Client Secret | Shared secret for confidential clients. Never expose in client-side code. Rotate on breach. Prefer signed JWT assertion for stronger auth. |
| iss (issuer) | URL of the authorization server that issued the token. Validate against expected AS. |
| sub (subject) | Unique identifier for the user or service. Stable across tokens. Use for identity, not email. |
| aud (audience) | Intended recipient(s). Must match the resource server's identifier. Always validate. |
| exp (expiry) | Unix timestamp after which the token is invalid. Always validate — reject expired tokens. |
| iat (issued at) | When the token was issued. Use for token age checks, not as primary expiry. |
| jti (JWT ID) | Unique token identifier. Use for token replay detection and revocation tracking. |
| scope | Space-separated list of granted scopes. Enforce on the resource server per endpoint. |
| azp (authorized party) | Client ID the token was issued to. Useful in multi-client scenarios. |
| code_verifier | Random string (43–128 chars, URL-safe). Generated by the client. Never sent to the browser. |
| code_challenge | BASE64URL(SHA256(code_verifier)). Sent in the authorization request. |
| code_challenge_method | Always S256. Plain is insecure (challenge = verifier, defeats the purpose). |
| Token exchange | Client sends code + code_verifier. AS recomputes challenge and verifies. Code is useless without the verifier. |
| Why it matters | Even if the authorization code is intercepted (open redirect, log injection), it cannot be exchanged without the verifier the client holds. |
| Grant Type | Use Case | User Involved? | Refresh Token? | Security Notes |
|---|---|---|---|---|
| Authorization Code + PKCE | Web apps, SPAs, mobile apps — any user-facing client | Yes | Yes | Required flow for user-facing clients. PKCE mandatory for public clients. |
| Client Credentials | Service-to-service, background jobs, daemons | No | No | Client secret must be kept secure. Use signed JWT assertion over shared secret where possible. |
| Device Authorization | Smart TVs, CLIs, IoT devices with no browser | Yes (on second device) | Yes | User visits a URL on another device to authorize. Polling interval must be respected. |
| Refresh Token | Obtaining new access tokens silently | No (after initial auth) | Rotates | Rotate on every use. Detect replay via reuse detection. Invalidate full family on suspected theft. |
| Token Exchange (RFC 8693) | Service A impersonates user when calling Service B | Indirectly | Sometimes | Enables actor/subject distinction. Correct solution for delegation chains — not passing the user's token downstream. |
| Implicit (deprecated) | Was used for SPAs before PKCE | Yes | No | Tokens in URL fragment. Deprecated in OAuth 2.1. Do not use. |
| ROPC (deprecated) | Direct username/password to client | Yes (credentials exposed) | Yes | Client handles user credentials — defeats OAuth2's purpose. Deprecated. Do not use. |
code_verifier (43–128 chars, cryptographically random) and computes code_challenge = BASE64URL(SHA256(code_verifier)).
(2) The client redirects the browser to the AS /authorize endpoint with: response_type=code, client_id, redirect_uri, scope, state (random CSRF token), code_challenge, code_challenge_method=S256.
(3) The AS authenticates the user (login screen) and presents a consent screen listing the requested scopes. User approves.
(4) The AS redirects to redirect_uri?code=AUTH_CODE&state=SAME_STATE. The client validates state matches the value from step 2 (CSRF check).
(5) The client POSTs to /token: grant_type=authorization_code, code, redirect_uri, client_id, client_secret (confidential) or just client_id (public), code_verifier.
(6) The AS validates the code, computes SHA256(code_verifier), compares to the stored challenge. If valid, returns access token, refresh token, and ID token (if OIDC)./.well-known/jwks.json. Cache aggressively — refresh only on cache miss for an unknown kid.
(2) Verify signature: decode the JWT header, find the kid, use the matching public key to verify the signature algorithm matches (e.g., RS256). Reject if signature is invalid.
(3) Validate claims: check iss matches the expected AS; aud contains the RS's identifier; exp is in the future; iat is not too far in the past (clock skew tolerance).
(4) Check scope: the scope claim (or a custom claim) contains the permission required for the requested endpoint.
(5) Check alg: only allow expected algorithms (RS256, ES256). Reject none and symmetric algorithms like HS256 unless you explicitly share a secret.alg=none vulnerability is historical but worth knowing: early JWT libraries would accept unsigned tokens if the header declared alg: none. Also watch for algorithm confusion attacks — a RS256-signed token where the attacker changes the header to HS256 and signs with the RS's public key (which is, confusingly, treated as the HMAC secret by vulnerable libraries). Always validate the alg header against an explicit allowlist before selecting the verification key. Never let the token dictate the algorithm — your application dictates it.openid scope (signals an OIDC request), the ID token (a JWT containing identity claims: sub, name, email, iss, aud, exp), and the UserInfo endpoint (fetch additional user claims with an access token).
Rule: use the ID token to know who the user is. Use the access token to access protected resources. Never send the ID token to an API as an access credential.X-User-ID) to downstream services. Services trust the gateway's header rather than re-validating the full token on every hop — simpler, but requires network-level trust between the gateway and services.#access_token=...) after the authorization redirect. Problems: URL fragments appear in browser history, server logs, referrer headers, and JavaScript running in the page. Tokens were exposed to any code that could read the URL. The flow was designed for SPAs before PKCE existed, under the assumption that SPAs couldn't keep secrets — but the Implicit flow's "solution" created worse exposure than the problem it avoided.
Replacement: Authorization Code + PKCE. Tokens are never in the URL. The short-lived code in the redirect URL is useless without the code_verifier the client holds in memory. PKCE solves the "public client can't keep a secret" problem without putting tokens in URLs.resource:action pattern: invoices:read, invoices:write, users:admin, payments:initiate. This gives a clear, consistent vocabulary that maps directly to what the consent screen shows users. Avoid verbs that imply everything (admin, full_access) — they're impossible to revoke partially and create over-permissioning by default.
Separate read and write — a reporting client needs read only; a write-only integration should never have read. Group into logical resource boundaries so clients request a coherent set. Too many fine-grained scopes (50+) creates consent fatigue and management complexity; too few coarse scopes creates over-permissioning. Aim for 10–30 scopes for a typical API surface.reporting) which maps to a set of granular scopes internally. The consent screen shows the bundle name and description; the AS expands it. This keeps the user-facing consent simple while maintaining granular enforcement on the resource server.state parameter is a random, opaque value the client generates before the authorization redirect and binds to the user's session. When the AS redirects back with the authorization code, it includes the same state value. The client must verify that the returned state matches what it stored.
This prevents OAuth CSRF (Cross-Site Request Forgery): an attacker initiates an authorization flow, gets the redirect URL back (with the authorization code), but instead of following it themselves, tricks the victim's browser into loading it. The victim's session gets bound to the attacker's OAuth grant. The attacker can then log into the victim's session (if the app uses "Login with X") or the victim's account gets linked to the attacker's external account. State validation breaks this — the victim's browser has no matching state in its session./introspect endpoint to validate a token. The AS responds with active: true/false, scopes, expiry, and token metadata. It works for opaque tokens (random strings with no embedded claims) — the RS cannot decode them locally; only the AS knows their state. It also enables real-time revocation: when a token is revoked at the AS, the next introspection call returns active: false immediately, even before the token's exp has passed.
Use local JWT validation (JWKS) for high-throughput APIs where the AS call latency is unacceptable. Use introspection when you need real-time revocation, are using opaque tokens, or the AS is internal and low-latency. Cache introspection results keyed by token (short TTL) to reduce AS load.grant_type=client_credentials, client_id, client_secret, and scope. The AS validates the credentials, checks the requested scopes against what's allowed for this client, and returns an access token. No refresh token — when the access token expires, the client re-authenticates directly. Best practice: cache the access token until near expiry, then re-authenticate rather than fetching a new token for every request.
Securing the client secret: never hardcode in source or config files in version control. Use environment variables injected at runtime, or better, a secrets manager (Vault, AWS Secrets Manager). Rotate secrets on a schedule and immediately on suspected exposure. For higher assurance, replace the client secret with a signed JWT assertion (private_key_jwt or client_secret_jwt) — the client signs a short-lived JWT with a private key; the AS verifies with the registered public key.private_key_jwt is the next-best option — the private key never leaves the service, only the signed assertion is sent.device_code, user_code, and verification_uri. It displays "Go to example.com/activate and enter code WXYZ-1234." The user opens the URL on their phone or computer, authenticates, and enters the code. The device polls the AS token endpoint (with a specified interval) until the user completes authorization or the device code expires.slow_down — the device must then increase its interval by 5 seconds and maintain that for the rest of the flow. Respecting this is required by the spec and avoids being rate-limited or blocked. Also: device codes expire (typically 15–30 minutes). Design the user experience to make it clear what to do if the code expires — display a refresh button, not just a confusing error. The Device flow is correct for CLIs like kubectl login or aws sso login — it's the only flow that doesn't require a callback URL.git credential model. Or device flow with short-lived tokens refreshed on each invocation.kSecAttrAccessible values that require device unlock. Audit the specific API calls in your mobile OAuth2 library to confirm it's using hardware-backed secure storage, not just writing to a shared preferences file labeled "secure."/authorize): handles browser redirects, authenticates the user (delegating to an identity store or IdP), presents the consent UI, and issues the authorization code.
Token endpoint (/token): validates the grant (code, refresh token, client credentials), authenticates the client (client_secret or JWT assertion), issues tokens. Must be server-to-server only (no browser redirects).
Introspection endpoint (/introspect): validates a token and returns its metadata. Protected — only resource servers with registered RS credentials can call it.
Revocation endpoint (/revoke): accepts a refresh or access token and marks it invalid.
JWKS endpoint (/.well-known/jwks.json): public keys for JWT signature verification.
Discovery document (/.well-known/openid-configuration): machine-readable metadata listing all endpoints, supported flows, scopes, algorithms.tenant_id claim). Resource servers read the claim and enforce tenant data isolation. Simpler to operate; tenants cannot customize their auth (MFA policy, SAML federation). Per-tenant AS or realm: Keycloak calls these "realms" — each tenant gets an isolated configuration, user store, client registrations, and policies. Stronger isolation; higher operational overhead. OIDC discovery documents are tenant-scoped. Clients must know which tenant's AS to call (usually derived from the login domain or a tenant subdomain).https://as.example.com/realms/{tenant_id}/.well-known/openid-configuration. The client derives the tenant from the user's login domain or a pre-configured tenant identifier. Ensure your client libraries support dynamic AS discovery per tenant./login, which initiates the authorization code flow (server-side). After callback, the BFF exchanges the code for tokens at the AS. The BFF stores tokens in a server-side session (Redis/DB) keyed to a session ID. It sets a session httpOnly cookie on the browser. The SPA's API calls go through the BFF — the BFF reads the session, attaches the access token, and proxies to the resource server. The SPA never sees a token.SameSite=Strict prevents CSRF but breaks some cross-origin scenarios; SameSite=Lax is a reasonable default for most SPAs. The BFF is also where you add rate limiting, logging, and request tracing for auth flows — easier to centralize here than in every SPA page.kid. This reduces AS dependency to nearly zero for JWT validation. Structure: each service validates tokens locally using the cached JWKS; no per-request AS call. For introspection-required scenarios (opaque tokens, real-time revocation), cache introspection results with a short TTL (30–60 seconds) keyed by token hash — never by plaintext token, and never in a shared cache without careful isolation. Use an API gateway as the single token validation point and propagate identity as a trusted downstream header to reduce per-service validation overhead.X-User-ID, X-Scopes) requires network-level controls — services must only accept these headers from the gateway, not from external clients. Implement this via mTLS between the gateway and services, or network policy (only the gateway's IP range can reach internal services on the trusted port). At extreme scale (millions of tokens/second), pre-compute scope claims into a compact bitmap or set at issuance time so scope checking is a bitwise operation, not a string scan.grant_type=urn:ietf:params:oauth:grant-type:token-exchange, the user's token as the subject_token, and Service A's own credentials as the actor. The AS issues a new token with sub=user, act=service-a, scoped for Service B.act claim creates an audit trail: "Service A, acting as User X, called Service B." Without this, audit logs for downstream services show only the user — you lose visibility into which service made the call. The implementation complexity is real: your AS must support RFC 8693 (not all do), every service in the chain must participate, and the scope design must work across service boundaries. For simpler use cases where you just need to propagate user identity, passing a validated user claim as a trusted header from the API gateway is often sufficient — and dramatically simpler than implementing full token exchange.resource:action format. Publish them in the AS discovery document so clients can discover them programmatically.
Enforcement: the resource server is responsible for enforcing scopes — the AS only issues what's allowed; the RS checks what's required. Use middleware or a decorator per endpoint to declare the required scope. Never rely on the client requesting only the right scopes — validate on every request at the RS.admin scope because it's easier than asking for the specific scopes they need; Team B grants it to unblock Team A; admin is now granted to 15 clients that don't need it. Solve this with a scope request process (PR to the scope registry), automatic detection of clients holding more scopes than they've exercised (based on AS and RS audit logs), and quarterly scope reviews. Unused scopes on production clients are a standing vulnerability.sub or scope to a legacy API key and makes the call. The legacy credentials stay in the broker; the calling service never sees them.
Legacy → OAuth2-protected API (inbound): the legacy system can't do OAuth2. Use a gateway adapter: the gateway accepts the legacy auth (API key, Basic Auth) and mints a short-lived token (or passes a trusted identity header) for the downstream OAuth2-protected service. The gateway is the trust boundary.acr (Authentication Context Class Reference) claim. If the claim indicates insufficient assurance (e.g., acr: password-only but acr: mfa is required), the RS returns a 401 with a WWW-Authenticate header containing acr_values="mfa". The client initiates a new authorization request with acr_values=mfa and prompt=login. The AS challenges the user for MFA and issues a new token with acr: mfa.client_id, user_id (for user flows), scopes requested vs. granted, IP address, and a correlation ID. Ship logs to your SIEM (Splunk, Datadog, Elastic). Alerts: failed client authentication rate (brute force on client secrets), unusual token issuance spikes (compromised client minting tokens at scale), refresh token reuse detection events (stolen token signal), and clients requesting scopes they've never been granted.jti as a correlation key) gives you the full picture: token issued at 14:03, used to access /admin/users 47 times, by IP 203.0.113.5 — an anomaly the AS logs alone would miss. Build this correlation into your observability platform. For compliance requirements (SOC2, PCI-DSS), the AS audit log is required evidence: every privileged access token issuance must be traceable to a user, a client, and a set of scopes. Treat AS logs with the same retention and integrity requirements as financial audit logs.myapp://callback) or universal/app links (more secure — requires domain ownership verification). Store refresh tokens in the OS secure storage (Keychain / Keystore with hardware backing). Use AppAuth (iOS/Android) rather than implementing from scratch.https://app.example.com/*) or partial matches, an attacker can register a client with a redirect pointing to an attacker-controlled URL (https://app.example.com.evil.com/callback). The authorization code lands at the attacker. Validate redirect URIs with exact string comparison against the registered values — no prefix matching, no wildcard, no subdomain matching unless explicitly required and carefully scoped. Also: dynamic client registration (RFC 7591) is powerful but dangerous in public environments — anyone can register a client. Restrict it to authenticated requests from admin users or internal services, or disable it entirely if you don't need it.Authorization: ApiKey header path). Phase 3: per-client migration with white-glove support for large consumers. Phase 4: remove API key support after the deprecation date.aud validation, and scope enforcement so teams don't implement it themselves; and an API gateway that handles auth for external-facing services, propagating identity downstream as trusted headers. Internal service-to-service auth uses Client Credentials with Workload Identity (IRSA, OCP STS, Vault AppRole) — no shared secrets.private_key_jwt instead of shared secrets. (2) Authorization code interception — code in the redirect URI intercepted via open redirect, referrer header, or malicious app. Mitigation: PKCE (code useless without verifier), exact redirect URI matching. (3) Refresh token theft — long-lived, grants indefinite access. Mitigation: rotation with reuse detection, secure storage, short-lived access tokens. (4) AS compromise — the AS is the trust anchor; compromise means unlimited token issuance. Mitigation: HSM for signing keys, air-gapped key management, strong AS infrastructure security. (5) Open redirector — AS redirects to attacker-controlled URL. Mitigation: exact URI matching, no wildcards.invoices:read in Team A, invoice.view in Team B, GET_INVOICES in Team C). Client registrations accumulate in each team's IdP tenant. Cross-team API access becomes a negotiation between two teams' scope schemas. The authorization design reflects the org's communication failures, not just its technical structure.profile:read, orders:read, orders:write, payments:read. The consent screen lists exactly what each scope grants in plain language. Clients declare required scopes at registration; AS enforces that clients cannot request undeclared scopesorders:read is rejected by the payments endpoint even if the token is otherwise validdata:read that cover all customer data — users cannot meaningfully consent to what they're approving; a compromised client with this scope accesses everythinginternal-secret-key) that is hardcoded in every service's config. The key has never been rotated. A recent audit found the key in a public GitHub repository. Design a secure M2M authorization system that replaces the shared API key with OAuth2.inventory-service:read, inventory-service:write. Service A that needs to read inventory requests inventory-service:read. The inventory service validates this scope on every inbound requestjti values to a blocklist. Otherwise, accept the remaining TTL as the blast radius window — short token lifetimes pay off herejti values. Build a timeline: what endpoints were called, what data was accessed, any data exported or modified. This is your incident report and breach notification evidenceacr_values=mfa and prompt=login to force a full re-auth challenge. Invalidate all existing sessions, not just the compromised one