Follow the OAuth 2.0 Security BCP (RFC 9700): use the authorization code flow with PKCE, match redirect URIs by exact string, bind the transaction to the browser with state and (for OIDC) nonce, and defend mix-up with the iss parameter. Drop the implicit and password grants. Rotate or sender-constrain refresh tokens, audience-restrict access tokens, and keep tokens out of the SPA using a backend-for-frontend.
- Do use the authorization code flow with PKCE (
S256) for every client, and make the authorization server enforce the verifier and reject PKCE downgrade. - Don't use the implicit or resource-owner-password grants, and match redirect URIs by exact string, never by wildcard or prefix.
- Do bind each flow to the browser with a single-use state (and OIDC nonce), and validate the
issresponse parameter when more than one issuer is possible. - Don't leave refresh tokens as long-lived bearer tokens for public clients; rotate them or sender-constrain with DPoP or mTLS.
- Do audience-restrict access tokens and have each resource server reject tokens not meant for it.
- Don't store tokens in the SPA's
localStorage; prefer a backend-for-frontend with aSecure,HttpOnly,SameSitecookie.
| Setting | Value | Note | Source |
|---|---|---|---|
redirect_uri matching | Exact string match against a per-client allowlist | Only exception: the port of a localhost redirect for native apps. | RFC 9700 Sec. 2.1 / ASVS 5.0 V10.4.1 |
| PKCE | Required, method S256; AS rejects a verifier sent without a challenge | plain not used in new clients; downgrade protection is mandatory. | RFC 9700 Sec. 2.1.1 / RFC 7636 |
| Authorization code | Single use; lifetime <= 10 min (L1/L2), <= 1 min (L3) | On a second use, reject and revoke tokens issued from that code. | ASVS 5.0 V10.4.2-10.4.3 |
| state / nonce | 128-bit random, single-use, bound to the browser session | state RECOMMENDED for CSRF; OIDC nonce binds the ID Token. | RFC 9700 Sec. 2.1 / OIDC Core 3.1.2.1 |
| Implicit & password grants | Do not use | Use code + PKCE; response type token and ROPC are removed. | RFC 9700 Sec. 2.1.2 / 2.4 |
| Mix-up defense | Validate the iss response parameter by exact match | Required whenever the client talks to more than one authorization server. | RFC 9700 Sec. 2.1 / RFC 9207 |
| Refresh tokens (public clients) | Rotate (invalidate old, revoke family on reuse) or sender-constrain | Give refresh tokens an absolute expiry even with sliding renewal. | RFC 9700 Sec. 2.2.2 / 4.14 |
| SPA token storage | Backend-for-frontend; cookie Secure + HttpOnly + SameSite=Strict, Path=/, no Domain | Tokens stay in the backend; never in localStorage. | RFC 10017 Sec. 6.1 / 8 |
- Register
https://app.example/callback, then request authorization withredirect_uriset to a prefix or subdomain variant; the authorization server must reject it. - Start a flow, then redeem the code at the token endpoint without the
code_verifier(or with a mismatched one); the request must fail. - Replay a valid authorization code a second time; the server must reject it and revoke any tokens already issued from it.
- Return an authorization response to the callback with a missing or altered state; the client must abort and create no session.
- With two configured issuers, return a response whose
issparameter names the other issuer; the client must reject it. - Reuse a rotated (already-redeemed) refresh token; the server must reject it and revoke the whole refresh-token family.
- Applies to
- OAuth 2.0 Security BCP (RFC 9700) January 2025
- OAuth 2.0 for Browser-Based Apps (RFC 10017) August 2026
- OpenID Connect Core 1.0 errata set 2
- OWASP ASVS 5.0.0
OAuth 2.0 clients and OpenID Connect relying parties, including browser-based apps. Provider support for PKCE, DPoP and issuer identification varies.
- Part of
- Workflows: Fix a finding, Test an application
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Map each RFC 9700 attack class to its countermeasure.
- Configure PKCE, state, iss, and exact redirect matching correctly.
- Choose refresh-token, sender-constraint, and SPA storage strategies.
Before you start
Engineers integrating OAuth or OIDC clients and resource servers, and reviewers assessing a delegated-authorization deployment.
- Follow an HTTP redirect and a browser session.
- Distinguish authentication, authorization, and delegation.
- Read a token or request trace.
OAuth 2.0 delegates access: a client obtains an access token to call a resource server on a user's behalf, and OpenID Connect adds an ID Token that authenticates the user to the client. Four parties have distinct jobs: the authorization server issues codes and tokens, the client starts the flow and redeems the code, the browser carries redirects, and the resource server accepts access tokens. Attacks target the seams between them: the redirect back to the client, the code-for-token exchange, the browser's role as an untrusted courier, and where the resulting tokens are stored.
The authoritative baseline is RFC 9700, the OAuth 2.0 Security Best Current Practice (January 2025, BCP 240), which updates the original RFCs with concrete countermeasures; RFC 10017 (August 2026) covers browser-based apps, and the draft OAuth 2.1 consolidates the same rules. This guide works through the attack classes RFC 9700 enumerates and the defense for each. It does not re-derive a correct login trace end to end; that is the subject of the companion guide at /domains/identity-crypto/authentication/oauth-oidc-login/, which builds a login flow that stays bound to its browser.
One idea runs through every defense: a value that arrived through the browser is an input to a pending transaction, not proof of anything. The client must have created server-side state before the redirect, and must check the callback against that state rather than trusting what the callback carries.
If the authorization server validates redirect_uri with anything looser than exact string comparison, an attacker steers the authorization response to a host they control. A pattern like https://*.app.example/* that a naive matcher reads as "any characters" accepts https://attacker.example/.app.example; a path-prefix match accepts an open redirector or a path the attacker controls. RFC 9700 Section 2.1 requires exact string matching against a per-client allowlist, with the only exception being the port number of a localhost redirect for a native app.
A second, related hazard is an open redirector anywhere in the client or authorization server: a URL parameter that forwards the browser to an arbitrary site can be chained to exfiltrate codes or tokens through the Referer header or a redirect hop. RFC 9700 requires that neither the client nor the authorization server expose open redirectors, and that the authorization server authenticate the user before redirecting and only auto-redirect to trusted URIs. Watch for dangling DNS records too: a subdomain takeover inside an allowed domain defeats even correct wildcard handling. The detection and prevention steps are in /domains/runtime/cloud-posture/subdomain-takeover/.
The Node helpers below show the shape: redirectAllowed does a set-membership exact match, and pkce() generates the transaction secret described next. Reject a redirect_uri that differs by a query string, a trailing path, or case; if it is not byte-for-byte a registered value, it is not allowed.
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
// Per-client allowlist of fully-formed redirect URIs. The AS compares by exact
// string match (RFC 9700 Sec. 2.1): no wildcards, no prefix or path matching.
const REGISTERED = new Set(['https://app.example/callback']);
export const redirectAllowed = (uri) => REGISTERED.has(uri);
// PKCE (RFC 7636): 32 random octets -> 43-char verifier; S256 challenge.
export function pkce() {
const verifier = randomBytes(32).toString('base64url'); // >= 256 bits
const challenge = createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge, method: 'S256' }; // never "plain"
}
// state and nonce are opaque, 128-bit, single-use, and compared constant-time.
export const opaque = () => randomBytes(16).toString('base64url');
export function sameValue(a, b) {
const x = Buffer.from(a), y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
}Authorization code injection is the core attack the code flow must resist. An attacker who captures a victim's authorization code (from logs, history, a Referer leak, or a mis-scoped redirect) injects it into their own session with the legitimate client; without a binding, the client redeems the victim's code and the attacker's session is now authenticated as the victim. Proof Key for Code Exchange (PKCE, RFC 7636) binds the code to a per-transaction secret. The client sends code_challenge = BASE64URL(SHA256(verifier)) with the authorization request and the verifier only at token exchange; a code minted for one challenge cannot be redeemed with a different verifier.
RFC 9700 requires PKCE for public clients and recommends it for confidential clients, and requires the authorization server to support it. Use the S256 method; plain exposes the verifier in the request and is only for clients that genuinely cannot compute SHA-256. Generate the verifier from a CSPRNG (32 octets gives a 43-character, 256-bit value within the 43-128 range the RFC defines).
The authorization server must also stop the PKCE downgrade attack (Section 4.8): an attacker strips the code_challenge from their own request, obtains a code bound to no challenge, and injects it into the victim's client, which redeems it with its verifier. The server must reject a token request that presents a code_verifier when the authorization request carried no code_challenge. The enforce_pkce function below encodes exactly that rule, and verifies the matching case against the RFC 7636 test vector.
import base64, hashlib, secrets
def _b64url(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def create_pkce() -> tuple[str, str]:
# 32 random octets -> 43-char verifier (>= 256 bits); S256 challenge.
verifier = _b64url(secrets.token_bytes(32))
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
return verifier, challenge # method is always "S256"; "plain" is not used
def enforce_pkce(challenge_on_request: str | None,
verifier_on_token: str | None) -> bool:
# Stop the PKCE downgrade attack (RFC 9700 Sec. 2.1.1 / 4.8):
if challenge_on_request is None:
return verifier_on_token is None # reject a verifier added without a challenge
expected = _b64url(hashlib.sha256(verifier_on_token.encode("ascii")).digest())
return secrets.compare_digest(expected, challenge_on_request)Cross-site request forgery on the redirect endpoint lets an attacker complete a flow of their choosing in the victim's client, for example linking the attacker's account to the victim's session. The defense is a state value: a single-use, high-entropy token bound to the initiating browser session and checked on the callback. PKCE provides equivalent CSRF protection, but only once the client has confirmed the authorization server supports PKCE; until then, use state (or, in OIDC, nonce). In OIDC, the nonce additionally binds the issued ID Token to this authentication request and is checked as the nonce claim.
Mix-up attacks apply when a client talks to more than one authorization server, at least one of them attacker-controlled. The attacker makes the client send a code it obtained from an honest server to the attacker's token endpoint, or the reverse. Storing only the authorization-server URL is not enough, because an attacker can front an honest authorization endpoint while declaring their own token endpoint. The client must record the issuer it started with and compare it to the iss parameter the authorization server returns (RFC 9207), using exact string comparison, aborting on mismatch. Distinct per-issuer redirect URIs are an alternative defense.
Reduce leakage that feeds these attacks: set Referrer-Policy to suppress the Referer header, keep third-party content off the callback page, use the authorization code flow rather than token-returning response types, and invalidate state and codes after first use. For in-browser message passing (postMessage), both sender and receiver origins must be checked by exact string match, and a wildcard target origin must never be used.
state: single-use, >= 128-bit, bound to the browser session, checked on callback.nonce(OIDC): bound into the ID Token and verified as thenonceclaim.iss(RFC 9207): compared by exact match whenever multiple issuers are possible.postMessage: exact-match sender and receiver origins; never a'*'target origin.
The implicit grant (response type token) returns an access token in the URL fragment. It predates PKCE and gives no protection against token injection, and the token lands in browser history and Referer headers. RFC 9700 says clients should not use response types that issue access tokens from the authorization endpoint; use the authorization code flow (optionally code id_token when an ID Token is needed at the endpoint) and obtain access tokens from the token endpoint instead. ASVS 5.0 marks the implicit grant as one that must no longer be used.
The resource-owner-password-credentials grant has the client collect the user's password and exchange it directly. It defeats the entire point of delegated authorization, exposes the password to the client, and is incompatible with federation, multi-factor, and passkeys. RFC 9700 states it must not be used.
On the authorization-server side, allow each client only the grant types and response modes it actually needs, keep authorization codes short-lived and single-use, and revoke tokens if a code is replayed. These are the settings in the defaults table; enforcing them removes whole classes of flow confusion rather than mitigating them after the fact.
A refresh token is long-lived authority: whoever holds it can mint new access tokens. For public clients (SPAs, native apps) that cannot keep a client secret, a bare bearer refresh token is a high-value theft target. RFC 9700 requires such refresh tokens to be either sender-constrained or subject to rotation. Rotation issues a new refresh token on each use and invalidates the previous one; if an already-used, invalidated refresh token is presented, the server cannot tell the legitimate client from the thief, so it revokes the whole refresh-token family and forces re-authorization. That turns silent theft into a detected, contained event.
Give refresh tokens an absolute expiration even when sliding renewal is used, bind them to the client they were issued to, and expire them after a period of inactivity. Revoke them on security events such as password change or logout, and offer users a way to see and revoke active grants. Confidential clients authenticate on the token request, so their refresh tokens are already client-bound, but rotation still adds theft detection.
Sender-constraining is the stronger option where it fits: a refresh token bound to the client's DPoP key or mTLS certificate is useless without the corresponding private key. Combining rotation with sender-constraining gives both detection and prevention.
A bearer access token is usable by anyone who holds it. Sender-constrained tokens bind the token to key material the legitimate client controls, so a resource server rejects a token presented without proof of possession. RFC 9700 recommends this for access tokens, and ASVS 5.0 requires it at L3. Two mechanisms exist. Mutual TLS (RFC 8705) binds the token to the client's TLS certificate; the token carries a cnf claim with x5t#S256 (the base64url SHA-256 of the certificate's DER encoding), and the resource server compares it to the certificate from the TLS layer.
DPoP (RFC 9449) is an application-layer alternative that works for public clients. The client holds a key pair and sends a DPoP proof JWT (header typ dpop+jwt, an asymmetric alg, the public jwk) on each request, with claims htm (method), htu (URL), iat, jti, and, when presenting an access token, ath (the token's hash). The access token carries cnf with jkt, the JWK SHA-256 thumbprint (RFC 7638); the resource server checks the proof against that thumbprint. The token is presented with the Authorization: DPoP scheme, and servers can demand a server-issued nonce via the DPoP-Nonce header and the use_dpop_nonce error to stop proof pre-generation.
Sender-constraining shifts the target from the token to the private key, so it does not protect against an attacker who can run code in the client (for example, XSS using the key in place); keep the key non-extractable where the platform allows. For validating the access-token signature, audience, and type at the resource server, see the JWT attacks guide at /domains/identity-crypto/authentication/jwt-attacks/.
RFC 10017 ranks browser app architectures by security, and is explicit that no browser storage option prevents an attacker who can run JavaScript in the app from obtaining tokens: storage only decides whether existing tokens can be read directly. localStorage and sessionStorage are readable by any script in the origin; a JavaScript-accessible cookie is sent to the server automatically and is not recommended for tokens.
The recommended pattern for business, sensitive, or personal-data apps is the backend-for-frontend (BFF): a confidential OAuth client on the server holds the access and refresh tokens, and the browser authenticates to the BFF with a cookie-based session while the BFF proxies API calls. The SPA never sees a token, which removes single-execution and persistent token theft. Configure the session cookie as shown: Secure, HttpOnly, SameSite=Strict, Path=/, no Domain, and a prefix that marks it as HTTP-set. Add a CSRF defense (a required non-safelisted header forcing a CORS preflight), because SameSite alone is insufficient when sibling subdomains share the site, and restrict the hosts and paths the BFF will proxy.
A BFF does not stop an attacker who already runs script in the app from proxying requests through the user's browser; that residual risk is why keeping untrusted script out of the origin (CSP, dependency hygiene) still matters. Where a full BFF is impractical, a token-mediating backend that holds the refresh token is the next option; a pure browser-based client that handles the whole flow is the weakest and should use PKCE, rotation or DPoP, and short, session-bound token lifetimes.
# Backend-for-frontend session cookie. The SPA never holds a token; the BFF is a
# confidential OAuth client and proxies every API call (RFC 10017 Sec. 6.1).
Set-Cookie: __Host-Http-session=<opaque-id>; Secure; HttpOnly; SameSite=Strict; Path=/
# __Host-Http- needs Secure + HttpOnly + Path=/ + no Domain. Where the browser does
# not support it yet (Safari as of 2026-10), fall back to the __Host- prefix.
# CSRF defense: require a non-safelisted request header, which forces a CORS
# preflight; the BFF rejects any state-changing request that lacks it.
# fetch('/api/pay', { method: 'POST', headers: { 'X-CSRF': token } })
# The BFF proxies only an allowlist of upstream hosts and paths, and may limit
# the HTTP methods per endpoint, so a hijacked session cannot reach arbitrary APIs.Treat the RFC 9700 attack list as a test plan. For each attack, script the hostile request and assert the server or client refuses it, and run those checks against the real authorization server and client, not a mock, because the defenses live in configuration (allowed grants, exact redirect matching, PKCE enforcement) as much as in code.
Useful tooling: your identity provider's conformance suite or the OpenID Foundation conformance tests for the profile you deploy, a proxy such as mitmproxy or Burp to tamper with redirect_uri, state, and codes, and the authorization server's own logs to confirm that a replayed code revokes its tokens. The table maps each attack to the control that must stop it and the exact manipulation to inject.
| Attack | Control that stops it | Manipulation to inject |
|---|---|---|
redirect_uri manipulation | Exact-match allowlist at the AS | Prefix, subdomain, or query-string variant |
| Authorization code injection | PKCE (S256) verifier binding | Redeem a code with a mismatched verifier |
| PKCE downgrade | Reject verifier when no challenge was sent | Strip code_challenge, then send a verifier |
| CSRF on callback | Single-use state bound to the browser | Callback with missing or altered state |
| Mix-up | Exact-match iss (RFC 9207) | Return a response naming the other issuer |
| Refresh token theft | Rotation + family revocation, or DPoP/mTLS | Reuse an already-redeemed refresh token |
Guided practice
You review a public SPA client and its authorization server. For each proposed change, decide whether it closes or opens an attack, and name the RFC 9700 countermeasure involved.
Supplied scenario material
Baseline: authorization code flow; redirect_uri registered as https://app.example/callback; the AS matches redirect_uri by exact string; PKCE S256 required and the verifier enforced; single-use state bound to the browser; one authorization server; refresh tokens rotated with family revocation; access tokens audience-restricted; tokens held by a backend-for-frontend in a __Host-Http- session cookie.
A: the team wants to accept any redirect_uri under https://app.example/ to simplify preview deployments.
B: the team wants to move access and refresh tokens into localStorage so a new mobile web view can read them.
C: the AS will stop requiring a code_challenge but will still check a code_verifier if one is sent.
D: the client adds a second, partner-run authorization server, keeping one shared redirect URI.
E: on refresh, the AS will keep the old refresh token valid for 60 seconds to ease retries.1. Keep redirect matching exact
Evaluate change A.
Expected result and reasoning
Reject A. Prefix matching under the host enables
redirect_urimanipulation and open-redirector chaining. RFC 9700 2.1 requires exact string matching; register each preview URI instead.2. Keep tokens out of the browser
Evaluate change B.
Expected result and reasoning
Reject B.
localStorageis readable by any script in the origin, so XSS or a bad dependency exfiltrates the tokens. Keep the BFF pattern (RFC 10017); the view should call the BFF, not hold tokens.3. Preserve PKCE downgrade protection
Evaluate change C.
Expected result and reasoning
Reject C. Accepting a verifier when no challenge was sent is exactly the PKCE downgrade attack (RFC 9700 4.8). The AS must reject a token request that carries a verifier when the authorization request had no challenge.
4. Add a mix-up defense with the second issuer
Evaluate change D and state the required addition.
Expected result and reasoning
D introduces mix-up exposure. Allow it only after adding issuer identification: validate the
issresponse parameter (RFC 9207) by exact match, or use distinct redirect URIs per authorization server.5. Keep rotation strict
Evaluate change E.
Expected result and reasoning
Reject E as written. A reuse window lets a stolen rotated token be replayed and weakens family-revocation detection. Make retries idempotent on the client instead; the old refresh token must be invalid immediately after use.
The fixture describes configuration and does not execute a live flow; confirm the real authorization server and client with a conformance suite and an intercepting proxy.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Repair the code
OAuth redirect and PKCE lab: Match redirect URIs and verify PKCE
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- RFC 9700: Best Current Practice for OAuth 2.0 Security (Sec. 2 and 4). January 2025 (BCP 240).
- RFC 10017: OAuth 2.0 for Browser-Based Applications (BFF, token storage). August 2026 (BCP 212).
- RFC 7636: Proof Key for Code Exchange (S256, Appendix B vector). September 2015.
- RFC 9207: OAuth 2.0 Authorization Server Issuer Identification (iss). March 2022.
- RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP). September 2023.
- RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Tokens. February 2020.
- OpenID Connect Core 1.0 (ID Token validation, Sec. 3.1.3.7). Final, incorporating errata set 2.
- OWASP ASVS 5.0.0 V10: OAuth and OIDC. Version 5.0.0 (May 2025).
Reference information
- SDLC phase
- Development
- Role
- AppSec engineers
- CAPEC attack patterns