Why OAuth Security Is So Hard to Get Right
OAuth 2.0 is a framework specification, not a protocol. RFC 6749 deliberately leaves many implementation details to the implementor β which means there are dozens of ways to build an OAuth flow, and many of them are insecure. The RFC authors acknowledged this; the document itself says "this specification is designed to provide a clear, simple, and flexible approach to implementing OAuth" β a set of objectives that turns out to be in tension.
The attack surface for OAuth implementation flaws is large because the flow involves multiple parties (resource owner, client, authorization server, resource server), multiple redirect steps, and state that needs to be maintained across those redirects. Each handoff is an opportunity for an attacker to intercept or manipulate the flow.
Use a library: If you're implementing OAuth 2.0 from scratch in 2026, seriously reconsider. Libraries like authlib (Python), passport-oauth2 (Node.js), and spring-security-oauth2 (Java) handle most of these pitfalls correctly. The vulnerabilities in this post are almost exclusively found in custom implementations.
The OAuth 2.0 Authorization Code Flow and Where It Breaks
The authorization code flow is the correct grant type for most applications. At a high level:
- Client redirects user to authorization server with
response_type=code,client_id,redirect_uri,scope, andstate - User authenticates and grants consent at the authorization server
- Authorization server redirects back to
redirect_uriwith an authorization code and the originalstate - Client verifies
state, then exchanges the code for tokens via a back-channel request (server to server) - Authorization server returns access token and refresh token
The vulnerabilities cluster around steps 1, 2, and 3 β the redirects and the state verification.
Open Redirect Attacks in OAuth: Stealing Auth Codes
The authorization server validates the redirect_uri parameter against a list of registered redirect URIs for the client. If this validation is weak β using substring matching, prefix matching, or allowing wildcard subdomains β an attacker can construct a redirect URI that the authorization server accepts but that points to an attacker-controlled endpoint.
--- Legitimate authorization URL --- https://auth.example.com/oauth/authorize? client_id=myapp& redirect_uri=https://app.example.com/callback& response_type=code& state=abc123 --- Attacker's manipulated URL (if AS uses prefix matching) --- https://auth.example.com/oauth/authorize? client_id=myapp& redirect_uri=https://app.example.com.attacker.com/callback& response_type=code& state=abc123 --- Or with path traversal (if AS matches only the base path) --- redirect_uri=https://app.example.com/callback/../../../attacker --- Auth code sent to attacker's server --- --- Attacker exchanges it for tokens via legitimate token endpoint ---
The fix is exact matching: Authorization servers must compare the redirect_uri parameter against the registered URIs using exact string matching. No prefix matching, no path normalization before comparison, no wildcard subdomains. This is explicit in the OAuth Security BCP (RFC 9700).
CSRF in OAuth Flows: State Parameter Misuse
The state parameter in the authorization request serves as a CSRF token for the OAuth flow. The client generates a random value, includes it in the authorization request, and verifies it when the authorization server redirects back with the authorization code.
Without a valid state check, an attacker can trick a user's browser into completing an OAuth flow that the attacker initiated β resulting in the user's account being linked to the attacker's identity ("account linking attack") or the user being logged into the attacker's account.
import secrets, hashlib from flask import session, request, abort # When initiating the OAuth flow: def start_oauth(): state = secrets.token_urlsafe(32) # cryptographically random session["oauth_state"] = state # store in server-side session return redirect_to_auth_server(state=state) # When handling the callback: def oauth_callback(): returned_state = request.args.get("state") expected_state = session.pop("oauth_state", None) # ALWAYS verify state before using the code if not returned_state or not expected_state: abort(400, "Missing state parameter") if not secrets.compare_digest(returned_state, expected_state): abort(400, "State mismatch β possible CSRF attack") # Now safe to exchange the authorization code code = request.args.get("code") exchange_code_for_tokens(code)
Token Leakage via Referrer Headers
When an authorization code or access token appears in a URL β as the implicit flow's response or as a query parameter in the callback URL β that URL can be leaked in the browser's Referer header when the page makes requests to third-party resources (analytics scripts, CDN resources, embedded widgets).
The authorization code flow is less vulnerable because the code appears in the callback URL only briefly and is single-use. But if your callback page includes any third-party scripts before consuming and clearing the code, those scripts' host servers will receive the code in the Referer header.
The mitigations: include a Referrer-Policy: no-referrer header on callback pages, use response_mode=form_post (which sends the code in a POST body, not a URL) where the authorization server supports it, and consume and clear the code immediately in the callback handler before rendering anything.
Implicit Flow: Why It's Retired and Still Dangerous
The implicit flow (response_type=token) was designed for browser-based apps that couldn't keep a client secret. Instead of an authorization code exchanged back-channel, it returns an access token directly in the URL fragment. This means the access token is:
- In the browser's address bar (visible to browser extensions, screenshot tools)
- In the browser's history (accessible by any JavaScript on the page)
- In server logs if the URL is logged
- In Referer headers sent to third-party resources on the page
OAuth 2.0 Security Best Current Practice (RFC 9700) explicitly deprecates the implicit flow. Use the authorization code flow with PKCE for browser-based apps instead.
Still widely used: Despite being deprecated, we regularly find the implicit flow in use in applications written before 2019. If you're maintaining legacy OAuth code, check whether you're using response_type=token and migrate to response_type=code with PKCE.
PKCE and When It Can Still Be Bypassed
PKCE (Proof Key for Code Exchange, RFC 7636) prevents authorization code interception attacks. The client generates a random code_verifier, hashes it to create a code_challenge, sends the challenge with the authorization request, and then proves knowledge of the verifier when exchanging the code for tokens.
import secrets, hashlib, base64 def generate_pkce_pair(): # Generate code verifier (43-128 chars, URL-safe) code_verifier = secrets.token_urlsafe(64) # S256 challenge method (always use S256, never "plain") digest = hashlib.sha256(code_verifier.encode()).digest() code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() return code_verifier, code_challenge # Authorization request includes: # code_challenge=<challenge>&code_challenge_method=S256 # Token request includes: # code_verifier=<verifier>
PKCE bypasses occur when:
- The authorization server accepts
code_challenge_method=plainβ which doesn't hash the verifier, defeating the purpose - The authorization server doesn't enforce PKCE for public clients β allows clients to skip it entirely
- The
code_verifieris predictable β using a static value or a timestamp-based value rather than a cryptographically random one
Mix-Up Attacks Across Multiple Providers
Mix-up attacks occur when an application supports multiple authorization servers (e.g., "Sign in with Google" and "Sign in with GitHub"). An attacker can manipulate the flow so that an authorization code intended for one provider is sent to another β potentially stealing it or triggering an authorization exchange at an attacker-controlled server.
The mitigation is to include the authorization server's issuer identifier in the state parameter and verify it in the callback β ensuring the callback was actually triggered by the expected authorization server. OpenID Connect handles this correctly when you validate the iss claim in the ID token.
JWT as Access Tokens: Algorithm Confusion
When authorization servers issue JWTs as access tokens, resource servers that validate them are vulnerable to algorithm confusion attacks if they accept any algorithm. An attacker who knows the server uses RS256 (RSA) can forge tokens signed with the server's public key using HS256 (HMAC) β if the server doesn't explicitly require RS256 validation.
Always specify the expected algorithm explicitly when validating JWTs, and never accept the algorithm from the token header. For a detailed treatment of JWT security, see our post on Cryptographic Failures and JWT security.
OAuth Security Checklist for Implementers
- Use the authorization code flow with PKCE β for browser-based and mobile clients. Never use the implicit flow.
- Implement state parameter validation β always generate, store, and verify the state parameter. Use
secrets.compare_digestfor comparison. - Validate redirect URIs by exact match β no prefix matching, no path normalization, no wildcard domains.
- Use S256 PKCE β never use
plaincode challenge method. Require PKCE for all public clients. - Protect callback pages β add
Referrer-Policy: no-referrerand consume the auth code immediately before rendering. - Short-lived authorization codes β codes should expire within 60 seconds and be single-use.
- Validate the issuer on multi-provider setups β check the
issclaim when using OpenID Connect. - Specify the algorithm explicitly when validating JWTs β never accept algorithm from the token header.
- Rotate refresh tokens β implement refresh token rotation so that a leaked refresh token can't be used indefinitely.
- Use a battle-tested OAuth library β unless you're an authorization server implementer, use a library rather than rolling your own.
Test Your OAuth Implementation for Flaws
AquilaX SAST and DAST detect OAuth implementation flaws including open redirect vulnerabilities, missing state parameters, and insecure token handling.
Start Free Scan