Security Best Practices

This section documents OAuth 2.0 (RFC 6749) as amended by the OAuth 2.0 Security Best Current Practice (RFC 9700 / BCP 240), the OAuth 1.0 Protocol (RFC 5849) for historical context, and OpenID Connect Core 1.0, as published at the IETF Datatracker and the OpenID Foundation specifications — and, on the Spring pages, Spring Boot 4.1.x and Spring Security 7.1.x as published at the Spring Security reference documentation — which are the references these pages are written and verified against.

OAuth 2.1 is still an Internet-Draft (draft-ietf-oauth-v2-1-16, 3 September 2026) and is flagged as such everywhere it appears on these pages. It is a working-group consolidation in progress, not a published standard; nothing here should be read as saying otherwise.

This content was generated with the assistance of AI and should be verified against those official specifications before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

RFC 9700 — OAuth 2.0 Security Best Current Practice, published as BCP 240 — is the working group’s answer to fifteen years of attacks observed against real deployments. It does not define new wire format; it tightens the rules for the wire format RFC 6749 already defines, and several of its "SHOULD"/"MUST" clauses are exactly the clauses the in-progress OAuth 2.1 consolidation (draft-ietf-oauth-v2-1-16) folds back into the base specification. This page turns RFC 9700 into a checklist: one section per attack, each written the same way — threat, then mitigation, then how to test it — so it can be worked through top to bottom against a real deployment rather than read once and forgotten.

Every item below assumes the reader already knows the authorization-code flow from Authorization Code and PKCE; this page is about what goes wrong with it and how to close each gap, not about the flow itself.

Exact redirect-URI matching and open redirectors

Threat. The redirect_uri is where the authorization server sends the authorization code (or, on the legacy implicit grant, the access token itself) after the user approves the request. If the authorization server accepts anything that merely starts with, contains, or is a "close enough" match to a registered redirect_uri — a wildcard subdomain, an unanchored substring match, or a path-prefix match that lets an attacker append arbitrary path segments or query parameters — an attacker can register or find an endpoint under the same trusted host that immediately forwards its query string onward (an open redirector: a click-tracking link, an old marketing redirect, a misconfigured proxy) and use it to steer the authorization response to a URL they control. Because the browser is following redirects the authorization server itself issued, the victim’s browser, the victim’s session and the victim’s consent all look completely legitimate to every party except the one being robbed.

Mitigation. RFC 9700 §2.1 requires exact string matching of the whole redirect_uri against one of the values registered for the client — scheme, host, port and path, byte for byte, no wildcards and no pattern matching. A client should register the smallest number of exact URIs it actually needs. Authorization servers should additionally reject registration of redirect_uri values that are themselves open redirectors (any URI whose path is a generic "go to this other URL" endpoint) and should not treat a registered URI’s prefix as a license to accept arbitrary suffixes. For public clients, RFC 8252 adds narrower constraints again (loopback interfaces must use an ephemeral port with no path constraint relaxation, private-use URI schemes should be unique and unguessable) — covered in full on Native and Mobile Apps.

How to test it. Register https://app.example.com/callback and then send authorization requests with redirect_uri values that a naive matcher might accept: an appended path segment (https://app.example.com/callback/../evil), an extra query parameter, a trailing slash, a different port, a different scheme, and a sibling path on the same host that is a known open redirector (a marketing link shortener, an old OAuth client’s own callback that 302s onward). Every one of these must produce invalid_request (or a rendered error page, never a redirect) rather than being accepted. If the authorization server also proxies or hosts any generic redirect endpoint on the same origin as a registered redirect_uri, audit that endpoint separately as a potential open redirector regardless of whether OAuth ever calls it directly.

Authorization-code injection and replay

Threat. An authorization code is a one-time bearer credential handed to the client’s redirect endpoint. Two distinct attacks exploit it. Authorization code injection: an attacker obtains a valid authorization code (via network interception on an insecure channel, a malicious app on the same device intercepting a custom-URI- scheme redirect, or a compromised authorization endpoint) and injects it into the victim’s browser session — completing the flow as if the victim had authenticated as the attacker, or vice versa. Authorization code replay: the same code, if the token endpoint does not enforce single use, is redeemed a second time by whoever captured it, minting a second, independent token pair from one authorization grant.

Mitigation. RFC 9700 §2.2 makes PKCE (RFC 7636) mandatory for every client obtaining authorization codes, public and confidential alike — one of the three headline changes the OAuth 2.1 draft also promotes to the base specification. The client generates a random code_verifier, sends its S256 hash as code_challenge in the authorization request, and presents the original code_verifier in the token request; the authorization server rejects the exchange unless the hash matches. An attacker who intercepts only the authorization code cannot redeem it without also knowing the code_verifier, which never left the legitimate client. Separately, and regardless of PKCE, the authorization server must invalidate an authorization code after its first use — RFC 6749 §4.1.2 already requires this — and, on replay, must revoke every token previously issued from that code, since a replay is strong evidence the code was compromised.

How to test it. Redeem a valid authorization code once (expect success), then redeem the identical code a second time (expect invalid_grant, and confirm the tokens issued on the first exchange are now rejected by the resource server). Redeem a code with no code_verifier at all, and again with the wrong code_verifier, against a client registered as requiring PKCE — both must fail with invalid_grant. Confirm the authorization server does not silently permit code_challenge_method=plain unless the client has no other option (S256 should be the only method offered by default).

CSRF and state

Threat. Cross-site request forgery against the redirect endpoint: an attacker starts their own authorization flow, captures the authorization code sent back to their own account, and tricks the victim’s browser into completing the client’s redirect step with the attacker’s code — for example by getting the victim to click a link that lands directly on the client’s callback URL carrying the attacker’s code. If the client accepts it, the victim’s session becomes bound to the attacker’s downstream account (this is particularly dangerous for social login: the victim can end up unknowingly authenticated as, or with their local account linked to, the attacker’s identity).

Mitigation. The client generates an unguessable, per-request state value, stores it bound to the user’s browser session before redirecting to the authorization endpoint, and verifies on return that the state in the authorization response matches the one it stored for that same browser session. A state that is merely present, but not checked against a session-bound value, provides no protection at all — this is a common, incorrect implementation. PKCE’s code_verifier/code_challenge pair, because the verifier is also bound to the session that started the flow, provides an overlapping defence against this same class of attack even when state handling is sloppy, but RFC 9700 §2.4 recommends implementing both: state for CSRF, PKCE for injection/replay — they protect against different attacker positions and neither substitutes for the other.

How to test it. Start flow A as the attacker, capture the authorization response redirect. Start flow B as the victim in a separate session, then have the victim’s browser navigate directly to flow A’s captured redirect URL. The client must reject it because flow A’s state does not match anything stored for the victim’s session. Also test a request with no state parameter at all if the client makes it optional — RFC 9700 says the client should treat a missing or mismatched state as failure, not skip the check.

Mix-up attacks and iss

Threat. A mix-up attack targets a client configured to talk to more than one authorization server (a common setup: separate staging and production issuers, or several social-login providers behind one callback). The attacker positions a malicious or compromised authorization server as one of the client’s trusted issuers. When the client starts a flow believing it is talking to the honest authorization server, the attacker’s server intervenes and returns an authorization response (or redirects the front channel) that looks like it came from the honest one. If the client cannot tell which authorization server actually issued the response it is holding, it may send the authorization code — or the client credentials meant only for the honest server — to the attacker’s token endpoint instead, or conflate a code minted by one authorization server with the client configuration of another.

Mitigation. RFC 9207 defines the iss authorization response parameter: the authorization server includes its own issuer identifier in every authorization response, alongside code and state. The client, which already knows which issuer it expects for this flow (it chose that issuer before redirecting), compares the returned iss against the expected value and aborts if they do not match — closing the ambiguity a mix-up attack depends on. Where iss is not yet available (an older authorization server), RFC 9700 §4.4 recommends the weaker fallback of using a distinct redirect URI per authorization server the client trusts, so the client can at least infer which issuer a response came from by which endpoint received it — but iss is the complete fix and should be preferred wherever the authorization server supports it.

How to test it. Configure the client against two issuers with iss support. Start a flow against issuer A, then hand-craft (or use a test double for) an authorization response with issuer B’s iss value but otherwise valid parameters. The client must reject the response outright, before ever calling a token endpoint. Also confirm the client fails closed — refuses the flow — when iss is entirely absent from a response coming from an authorization server that advertises iss support in its discovery metadata.

sequenceDiagram participant U as Browser (victim) participant C as Client participant Honest as Authorization Server A (honest) participant Evil as Authorization Server B (attacker-controlled) C->>U: Redirect to authorization endpoint (client believes: A) U->>Evil: Authorization request actually reaches B Evil-->>U: Authorization response with code_B, no iss (or forged iss) U->>C: Deliver code_B to client's redirect_uri Note over C: Without iss, client cannot tell\nthis response came from B, not A C->>Honest: Wrong path: sends code_B / client_secret_A to A's token endpoint Honest-->>C: invalid_grant (code_B unknown to A) Note over C,Evil: With RFC 9207, response carries iss="B".\nClient compares against expected issuer "A",\nrejects immediately, never calls a token endpoint.

Counterfeit and malicious authorization servers

Threat. Related to the mix-up attack but broader: a client that discovers its authorization server dynamically (via a user-supplied issuer, an unvalidated link, or a spoofed discovery document) can be pointed at an authorization server the attacker fully controls. Every property of the flow — consent screen branding, scopes offered, the tokens ultimately issued — can be faked convincingly enough that a user cannot tell they are authorizing the wrong party. Counterfeit authorization servers are also the mechanism behind app-impersonation phishing, where a lookalike consent screen at a similar domain harvests the user’s real-provider credentials.

Mitigation. RFC 9700 §4.6 recommends that clients pin the authorization server’s issuer identifier out of band (configuration, not runtime discovery from an untrusted input) wherever possible, validate the discovery document’s issuer field matches the URL it was fetched from (RFC 8414 §3.3), and always fetch metadata and endpoints over TLS with full certificate validation — see the TLS section below. Never let user-supplied input (a "sign in with your company’s SSO" domain field, for instance) select an authorization server without validating it against an allow-list. On the human side, users should be trained to check the domain in the browser’s address bar during the authorization step, since that is the one signal a convincing fake cannot reproduce without also compromising DNS or TLS.

How to test it. Attempt to configure the client against an issuer URL whose discovery document’s issuer field does not match the URL fetched — the client must refuse to proceed. Attempt to point the client at an arbitrary, non-allow-listed issuer via any user-facing input and confirm it is rejected before any network call is made to it.

Access-token leakage

Threat. An access token that leaves the channel it was issued for is usable by whoever finds it, because a plain bearer token (RFC 6750) carries no proof of who it was issued to — see Sender-Constrained Tokens for the fix to that underlying property. Independently of sender-constraining, tokens leak through mundane channels that are easy to overlook: the Referer header sent to a third-party resource loaded from a page that has a token in its URL or in browser state; server access logs and reverse-proxy logs that record full request URLs and, on misconfigured servers, request bodies or headers; browser history, when a token is carried as a query parameter rather than a header or a POST body; and error pages or crash reports that echo back the full incoming request including its Authorization header.

Mitigation. Never place an access token in a URL (query string or path) — always the Authorization: Bearer header. Configure logging and reverse proxies to redact the Authorization header and any access_token/code/refresh_token query parameters before they are written to any log. Set Referrer-Policy: no-referrer or same-origin on any page that might carry a token in its URL, and avoid navigating away from such a page before it is safely rewritten (a history.replaceState immediately after an implicit-style fragment token, back when the implicit grant existed at all — see Legacy Implicit and Password Grants for why the fragment-token design itself is now retired). Ensure error pages and monitoring/APM tooling scrub bearer tokens and cookies from captured request data before they are stored or forwarded to a third-party service. Shorten access-token lifetimes so a leaked token has a smaller usable window, and prefer sender-constrained tokens (DPoP or mTLS) wherever the deployment can support them, since a leaked sender-constrained token is useless without the private key it is bound to.

How to test it. Grep application and proxy logs for Authorization: headers and bearer-looking strings after exercising every endpoint. Load a page carrying a token in any part of its URL and check the Referer header sent by the browser when that page loads a third-party image, script or stylesheet. Trigger a 500 error and inspect whatever crash-reporting or APM payload gets shipped for the raw request. Confirm access-token lifetimes are configured short enough (minutes, not days) that this whole class of leak has a bounded blast radius even when a leak does occur.

Refresh-token theft, rotation and reuse detection

Threat. A refresh token typically lives far longer than an access token and, for a public client with no client secret, is the closest thing to a long-lived credential in the whole system. If it is exfiltrated — from insecure storage, a compromised device, or a server-side data breach — the attacker can mint fresh access tokens indefinitely, well past the point the legitimate user would notice anything wrong.

Mitigation. RFC 9700 §4.14 requires refresh-token rotation for public clients (and recommends it broadly): every use of a refresh token returns a new refresh token and invalidates the one just used, so a refresh token is single-use even though the flow as a whole is long-lived. Rotation alone would let a thief simply race the legitimate client to redeem the current token first; the second half of the mitigation is reuse detection — if a refresh token that has already been rotated (and is therefore no longer current) is presented again, the authorization server treats this as strong evidence of compromise and revokes the entire token family (every access and refresh token descended from the original grant), forcing the user to re-authenticate. See Access and Refresh Tokens for the full rotation and reuse-detection state machine. Sender-constraining a refresh token (DPoP or mTLS) removes the theft risk almost entirely, since a stolen token cannot be redeemed without the matching private key.

How to test it. Redeem a refresh token, capture the new one issued, then redeem the original (now stale) refresh token a second time. The authorization server must reject it and revoke the token family — verify that the token obtained from the first (legitimate) rotation is now also rejected by the resource server. This is the single most important test on this page: a server that rotates without reuse detection provides no real protection against theft, only a race condition.

Threat. The authorization server’s consent screen ("Allow Example App to access your photos?") is rendered in a browser context the user trusts. If it can be loaded inside an invisible or disguised <iframe> on an attacker’s page, the attacker can overlay deceptive content and trick the user into clicking through a consent they never intended to grant — approving scopes, or an entirely different client, while believing they clicked something unrelated.

Mitigation. RFC 9700 §4.16 recommends the authorization server send X-Frame-Options: DENY (or the equivalent, more expressive Content-Security-Policy: frame-ancestors 'none') on every consent-screen response, so browsers refuse to render it inside a frame at all. Consent screens should also always display the actual client name and the actual requested scopes in a way that cannot be visually spoofed by the client itself (the authorization server, not the client, controls this rendering), and should require an explicit, deliberate user action (not a pre-checked box or a single unlabelled click) for consent to be captured as a genuine grant.

How to test it. Attempt to load the consent screen inside an <iframe> on a different origin and confirm the browser refuses to render it (a blank frame or a browser-level "refused to display" error), then confirm the same response headers are present on every consent-screen response, not just the first one in a session.

TLS requirements and certificate validation

Threat. Every OAuth message that matters — the authorization request and response, the token request and response, introspection, revocation, discovery — assumes the channel it travels over cannot be read or altered in transit. This is, quite deliberately, the entire replacement for what OAuth 1.0 did with per-request cryptographic signatures. When OAuth 2.0 dropped OAuth 1.0’s signature scheme in favour of bearer tokens over mandatory TLS, it traded a scheme where a captured request was useless without the signing key for one where transport security alone stands between an attacker and the token. That trade-off was controversial at the time — it is exactly the weakness Getting Started with OAuth 2.0 (2012) flags when it discusses the signatures debate — and everything in this section exists because "use TLS" is a much larger attack surface than "sign every request": a single misconfigured server, an unchecked certificate, or a client willing to fall back to plaintext defeats the entire security model in one step, whereas a compromised TLS channel does not by itself hand over a signing key under OAuth 1.0. See OAuth 1.0 vs. OAuth 2.0 for that trade-off in full, and Sender-Constrained Tokens for how the industry eventually re-solved it — not by bringing back per-request signatures, but by binding tokens to a key the client holds (DPoP, mTLS) so that transport compromise alone is no longer sufficient to steal a usable token.

Mitigation. RFC 6749 §10.9 and RFC 9700 §3 require TLS on every endpoint that transmits credentials or tokens: the authorization endpoint, the token endpoint, introspection, revocation, discovery, and any redirect carrying a code or token. TLS must be validated properly — the full certificate chain checked against a trusted root, hostname verification enforced, no acceptance of self-signed or expired certificates outside a deliberately pinned test environment, and modern protocol versions and cipher suites only (TLS 1.2 minimum, TLS 1.3 preferred). A client or resource server that disables certificate validation "to get past an error" has silently discarded the entire security model built on top of that channel.

How to test it. Attempt every OAuth-related HTTP call over plain http:// and confirm the authorization server refuses it (rather than silently downgrading). Point a test client at an endpoint presenting an invalid certificate (expired, wrong hostname, self-signed and not in the trust store) and confirm the client library raises a hard error rather than a warning that code can ignore. Confirm TLS 1.0/1.1 are disabled server-side.

scope escalation

Threat. A client requests — and a resource owner approves — a narrow set of scopes at authorization time, but the resulting access token ends up usable for capabilities beyond what was granted: because the authorization server silently widens scopes on a refresh-token exchange, because a resource server does not actually check the token’s scope claim before serving a request, or because a client requests broad scopes "just in case" and the authorization server grants them without the consent screen making the breadth visible to the user.

Mitigation. RFC 6749 §6 states plainly that a refresh-token exchange must not be used to obtain scopes broader than the original grant; if the client wants a narrower scope on a given exchange, RFC 9700 §4.13 recommends allowing that (down-scoping), but never a wider one. Resource servers must check the token’s actual scope (or the equivalent claim on an introspection response) against what the specific endpoint requires — holding a valid token is necessary but never sufficient. Consent screens should render scopes in human-readable, specific language rather than an opaque identifier, and authorization servers should support incremental authorization so clients request the minimum scope needed for the current operation rather than everything they might ever need.

How to test it. Request a token with scope read, then attempt a refresh_token exchange asking for scope read write — the authorization server must refuse or silently cap it back to read, never grant write. Call an endpoint that requires write with a read-scoped token and confirm the resource server returns insufficient_scope (RFC 6750 §3.1) rather than serving the request.

Checklist at a glance

The table below is the same ten items, condensed to one row each, for a quick pass over an existing deployment before diving into the full threat/mitigation/test sections above.

Attack Mitigation Primary reference

Redirect-URI mismatch / open redirector

Exact string match on the full redirect_uri; no wildcards or prefix matching

RFC 9700 §2.1

Authorization-code injection / replay

Mandatory PKCE for every client; single-use codes; revoke on replay

RFC 9700 §2.2, RFC 7636

CSRF on the redirect endpoint

Session-bound, unguessable state, verified on return

RFC 9700 §2.4

Mix-up attack

iss in the authorization response, compared against the expected issuer

RFC 9207

Counterfeit authorization server

Pin issuers out of band; validate discovery issuer matches the fetch URL

RFC 9700 §4.6, RFC 8414 §3.3

Access-token leakage (Referer, logs, history, error pages)

Never in a URL; redact logs; Referrer-Policy; short lifetimes; sender-constrain

RFC 9700 §4.13

Refresh-token theft

Rotation on every use, plus reuse detection revoking the whole token family

RFC 9700 §4.14

Clickjacking of the consent screen

X-Frame-Options: DENY / frame-ancestors 'none'

RFC 9700 §4.14.2

TLS downgrade / invalid certificate

TLS 1.2+ enforced, full chain and hostname validation, no plaintext fallback

RFC 6749 §10.9, RFC 9700 §3

scope escalation

No widening on refresh; resource server enforces scope per endpoint

RFC 6749 §6, RFC 9700 §4.13

What RFC 6819 adds

RFC 9700 supersedes the specific mitigations that had become outdated in RFC 6819 — OAuth 2.0 Threat Model and Security Considerations, published in 2013 — but RFC 6819 is still worth reading in full for the structure of its analysis, which RFC 9700 does not repeat: a systematic walk through every actor in an OAuth deployment (the authorization server, the client, the resource server, and the end user) and every message exchanged between them, cataloguing what an attacker positioned at each point in the flow can achieve. RFC 6819’s contribution that RFC 9700 assumes as background rather than restating includes: the taxonomy of attacker capabilities (eavesdropper, malicious client, malicious resource owner, malicious authorization server), the general countermeasure categories (confidentiality, integrity, replay prevention, and detection), and a threat-by-threat table that is still a useful checklist template for a threat-modelling exercise on a new deployment even where its specific recommendations have since been superseded by RFC 9700.

draft-ietf-oauth-security-topics-update is the OAuth working group’s active document for tracking security topics that have emerged since RFC 9700 was published — it is not yet a stable reference and its specific content should not be treated as settled practice, but it is the place to check for security guidance newer than this page.