---
key: "oauth-oidc-attacks"
title: "Defend OAuth and OIDC against redirect and token attacks"
url: "https://softwaresecurity.com/domains/identity-crypto/authentication/oauth-oidc-attacks/"
api: "https://softwaresecurity.com/api/v1/articles/oauth-oidc-attacks.json"
domain: "identity-crypto"
topic: "Identity \u0026 cryptography"
type: "Guide"
identifiers: ["CWE-601","CWE-352","CWE-346","CWE-294","CWE-287","A07:2025","A01:2025","API2:2023","V10","V3","V7","CAPEC-62","CAPEC-593"]
tags: ["Authentication and sessions","OAuth and OpenID Connect"]
stages: ["build"]
roles: ["appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Defend OAuth and OIDC against redirect and token attacks

Apply RFC 9700 and RFC 10017: exact redirect matching, PKCE with downgrade protection, state and iss binding, refresh-token rotation, sender-constrained tokens, and a backend-for-frontend so browser tokens cannot be stolen.

Canonical URL: https://softwaresecurity.com/domains/identity-crypto/authentication/oauth-oidc-attacks/

## In short

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 `iss` response 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 a `Secure`, `HttpOnly`, `SameSite` cookie.

## Recommended defaults

| 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 &lt;= 10 min (L1/L2), &lt;= 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 &amp; 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 |

## Verify it

1. Register `https://app.example/callback`, then request authorization with `redirect_uri` set to a prefix or subdomain variant; the authorization server must reject it.
2. Start a flow, then redeem the code at the token endpoint without the `code_verifier` (or with a mismatched one); the request must fail.
3. Replay a valid authorization code a second time; the server must reject it and revoke any tokens already issued from it.
4. Return an authorization response to the callback with a missing or altered state; the client must abort and create no session.
5. With two configured issuers, return a response whose `iss` parameter names the other issuer; the client must reject it.
6. Reuse a rotated (already-redeemed) refresh token; the server must reject it and revoke the whole refresh-token family.

## 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.

Audience: Engineers integrating OAuth or OIDC clients and resource servers, and reviewers assessing a delegated-authorization deployment.

Before you start:

- Follow an HTTP redirect and a browser session.
- Distinguish authentication, authorization, and delegation.
- Read a token or request trace.

Time: About 35 minutes: 13 to read and 22 for the exercise

## Where the attacks hit a delegated-authorization flow

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.

## Match redirect URIs exactly, and never be an open redirector

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.

```js
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);
}
```

## PKCE binds the code to the client, and blocks downgrade

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.

```python
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)
```

## Bind the callback to the browser and to the issuer

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, &gt;= 128-bit, bound to the browser session, checked on callback.
- `nonce` (OIDC): bound into the ID Token and verified as the `nonce` claim.
- `iss` (RFC 9207): compared by exact match whenever multiple issuers are possible.
- `postMessage`: exact-match sender and receiver origins; never a `'*'` target origin.

## Drop the implicit and password grants

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.

## Make refresh tokens detectable when stolen

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.

## Sender-constrain tokens so a stolen token is inert

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/.

## Keep tokens out of the SPA with a backend-for-frontend

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.

```http
# 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.
```

## Prove each defense against a concrete attack

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 material

```text
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.

Worked answer: Reject A. Prefix matching under the host enables `redirect_uri` manipulation 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.

Worked answer: Reject B. `localStorage` is 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.

Worked answer: 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.

Worked answer: D introduces mix-up exposure. Allow it only after adding issuer identification: validate the `iss` response parameter (RFC 9207) by exact match, or use distinct redirect URIs per authorization server.

### 5. Keep rotation strict

Evaluate change E.

Worked answer: 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.

Deliverable: A five-change decision record naming the attack each change would open or close and the specific RFC 9700 / RFC 10017 countermeasure.

Scope: 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.

## Executable practice

- [OAuth redirect and PKCE lab: Match redirect URIs and verify PKCE](https://softwaresecurity.com/labs/oauth-redirect-pkce/)

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [RFC 9700: Best Current Practice for OAuth 2.0 Security (Sec. 2 and 4)](https://www.rfc-editor.org/rfc/rfc9700.html#section-2). January 2025 (BCP 240).
- [RFC 10017: OAuth 2.0 for Browser-Based Applications (BFF, token storage)](https://www.rfc-editor.org/rfc/rfc10017.html). August 2026 (BCP 212).
- [RFC 7636: Proof Key for Code Exchange (S256, Appendix B vector)](https://www.rfc-editor.org/rfc/rfc7636.html#section-4). September 2015.
- [RFC 9207: OAuth 2.0 Authorization Server Issuer Identification (iss)](https://www.rfc-editor.org/rfc/rfc9207.html). March 2022.
- [RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://www.rfc-editor.org/rfc/rfc9449.html). September 2023.
- [RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Tokens](https://www.rfc-editor.org/rfc/rfc8705.html). February 2020.
- [OpenID Connect Core 1.0 (ID Token validation, Sec. 3.1.3.7)](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation). Final, incorporating errata set 2.
- [OWASP ASVS 5.0.0 V10: OAuth and OIDC](https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x19-V10-OAuth-and-OIDC.md). Version 5.0.0 (May 2025).