OAuth 2.0 is the backbone of modern authorization — embedded in every SaaS platform, mobile application, and third-party integration your organization depends on. It is also one of the most consistently misconfigured security protocols we encounter during web application penetration tests. The specification is long, the extension RFCs are scattered, and most developers integrate OAuth through libraries without reading the underlying security considerations. The result is a class of authorization vulnerabilities that automated scanners miss entirely and that carry account-takeover impact when exploited.
In this post we walk through the OAuth 2.0 grant flows that are most dangerous in practice, show real HTTP request sequences that demonstrate exploitation, and provide actionable defensive guidance for each attack class.
Understanding the Authorization Code Flow — and Where It Breaks
The authorization code grant is the recommended flow for server-side and public clients. At a high level it works like this: the client sends the user to the authorization server, the user authenticates and consents, the authorization server redirects back to the client with a short-lived authorization code, and the client exchanges that code for an access token over a back-channel server-to-server request. The security guarantee is that the access token never travels through the user's browser. When implementations deviate from that model, the attack surface opens dramatically.
A standard authorization request looks like this:
GET /oauth/authorize
?response_type=code
&client_id=app_client_123
&redirect_uri=https://app.example.com/callback
&scope=openid+profile+email
&state=xK9mP2qLrT4nV7wB
Host: auth.provider.com
The authorization server then redirects the browser back to the client:
HTTP/1.1 302 Found
Location: https://app.example.com/callback
?code=SplxlOBeZQQYbYS6WxSbIA
&state=xK9mP2qLrT4nV7wB
The client exchanges the code for a token on the back channel:
POST /oauth/token
Host: auth.provider.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https://app.example.com/callback
&client_id=app_client_123
&client_secret=s3cr3t_v4lue
Every parameter in that sequence is a potential attack surface. We'll cover each one.
Authorization Code Interception via Referrer Leakage
After the authorization server issues the redirect, the authorization code lives in the browser's address bar. If the callback page loads any third-party resources — analytics scripts, CDN-hosted fonts, marketing pixels, A/B testing libraries — the browser will include the full URL of the callback page in the Referer header of every sub-request. That header travels to third-party servers outside the application's control.
Consider a callback page that fires a Google Analytics beacon:
GET /collect?v=1&tid=UA-XXXXX-Y&t=pageview&dl=https%3A%2F%2Fapp.example.com%2Fcallback
%3Fcode%3DSplxlOBeZQQYbYS6WxSbIA%26state%3DxK9mP2qLrT4nV7wB
Host: www.google-analytics.com
Referer: https://app.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=xK9mP2qLrT4nV7wB
The authorization code is now logged in Google Analytics, in the browser history, in any proxy logs between the client and the analytics endpoint, and potentially in server access logs on the analytics provider's infrastructure. An attacker with access to any of those locations — or a compromised analytics account — can extract the code.
Authorization codes are single-use and short-lived, but "short-lived" is implementation-defined. We have encountered production deployments where codes remain valid for 10 minutes or longer. Codes issued to public clients (mobile apps, SPAs) without PKCE offer no binding between the original requestor and the code redeemer, meaning any party who obtains the code can exchange it for a token.
The fix is two-part: serve callback pages with a strict Referrer-Policy: no-referrer header, and ensure the application processes and discards the authorization code immediately upon receipt — before any third-party resources are requested.
Redirect URI Validation Bypass
The redirect_uri parameter is the most abused component of the OAuth flow. The authorization server is supposed to validate it against a pre-registered allowlist for that client. When validation is weak, an attacker can redirect the authorization code to a server they control.
Pattern 1: Prefix Matching Instead of Exact Matching
Many authorization servers perform prefix matching — accepting any URI that starts with the registered value. If https://app.example.com/callback is registered, a server using prefix matching also accepts:
redirect_uri=https://app.example.com/callback.evil.com/steal
The attack request looks like this:
GET /oauth/authorize
?response_type=code
&client_id=app_client_123
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback.evil.com%2Fsteal
&scope=openid+profile
&state=attacker_controlled
Host: auth.provider.com
The authorization server validates the prefix, finds a match, and redirects the user's browser — along with the authorization code — to https://app.example.com/callback.evil.com/steal, which is a domain the attacker controls.
Pattern 2: Path Traversal in Registered URIs
When the registered URI contains a path, implementations that do not normalize paths before comparison can be bypassed with directory traversal sequences:
redirect_uri=https://app.example.com/callback/../admin/steal
After URL decoding and path normalization in the browser, this resolves to https://app.example.com/admin/steal. Some authorization servers perform the comparison before normalization, accepting the URI even though the effective destination differs from the registered value.
Pattern 3: Open Redirect Chaining
An open redirect anywhere on the legitimate application domain can be combined with a lenient redirect URI validator to achieve full code interception. If https://app.example.com has an open redirect at /go?url=, the attack chain is:
redirect_uri=https://app.example.com/go?url=https://evil.com/steal
The authorization server accepts this because the host matches. The browser is redirected to the application, which immediately follows the open redirect to the attacker's server. The authorization code travels in the Referer header of the redirect, or is directly observable in the attacker's server logs.
Pattern 4: Subdomain Takeover
This is the most severe redirect URI attack. If an authorization server registers wildcard URIs (e.g., https://*.example.com/callback) or if a specific subdomain is registered that is no longer actively hosted, subdomain takeover creates an account-takeover primitive.
We regularly find abandoned DNS CNAME records pointing at decommissioned cloud infrastructure — S3 buckets, Heroku dynos, Azure endpoints — where the underlying resource was deleted but the DNS entry was never cleaned up. An attacker who claims that infrastructure gains a valid hostname under the target's domain. If that subdomain matches a registered OAuth redirect URI, they can receive authorization codes directly.
; DNS record still present, resource deleted
staging.app.example.com. CNAME d3abc123.cloudfront.net.
Attacker claims d3abc123.cloudfront.net, then initiates an OAuth flow with:
redirect_uri=https://staging.app.example.com/callback
Token Leakage via the Implicit Flow
The OAuth 2.0 implicit flow was designed for JavaScript single-page applications before the browser security model matured enough to support the authorization code flow with PKCE. In the implicit flow, the access token is returned directly in the URL fragment of the redirect — not as an authorization code that requires a back-channel exchange.
HTTP/1.1 302 Found
Location: https://app.example.com/callback
#access_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyX...
&token_type=bearer
&expires_in=3600
The access token now lives in the browser address bar for its full lifetime. Every problem that affects authorization codes in the URL is amplified: referrer leakage sends the full token to third parties, browser history stores it, and any JavaScript on the page can read window.location.hash. Because there is no code exchange step, there is no opportunity to bind the token to a specific client or verify that the requestor is the same party that initiated the flow.
The OAuth Security Best Current Practice (RFC 9700, published January 2025) explicitly prohibits the implicit flow. Current authorization server implementations should refuse response_type=token requests entirely. In our testing we still find production authorization servers that accept the implicit flow, particularly older enterprise identity providers and SaaS platforms that pre-date current guidance.
When testing an OAuth implementation, we always probe for implicit flow acceptance:
GET /oauth/authorize
?response_type=token
&client_id=app_client_123
&redirect_uri=https://app.example.com/callback
&scope=openid+profile
Host: auth.provider.com
If the server responds with a redirect containing an access_token fragment rather than rejecting the request, the implicit flow is active and should be flagged as a high-severity finding.
Missing and Bypassable PKCE Enforcement
Proof Key for Code Exchange (PKCE, RFC 7636) was introduced specifically to protect public clients — mobile applications and single-page applications that cannot securely store a client secret — from authorization code interception. The mechanism works by having the client generate a random code_verifier, hash it to produce a code_challenge, send the challenge at authorization time, and provide the original verifier at token exchange time. The authorization server binds the code to the challenge, so only the party that initiated the original request can redeem it.
A PKCE-protected authorization request:
GET /oauth/authorize
?response_type=code
&client_id=mobile_app_456
&redirect_uri=com.example.app://callback
&scope=openid+profile
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&state=n-0S6_WzA2Mj
Host: auth.provider.com
The token exchange must include the verifier:
POST /oauth/token
Host: auth.provider.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=com.example.app://callback
&client_id=mobile_app_456
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
We encounter two distinct PKCE failure modes in production.
The first is that PKCE is not required at all. Authorization servers that make PKCE optional allow an attacker to simply omit the code_challenge parameter from the authorization request. When the code is later intercepted, the attacker can exchange it without providing a verifier because none was registered.
The second failure mode is more subtle: the authorization server accepts a code_challenge_method of plain, which means the verifier is submitted as-is rather than hashed. Because the plain verifier travels in the authorization request URL — where it is visible to the same parties who might intercept the code — this provides no meaningful protection. The plain method should be disabled entirely in favor of S256.
We also regularly find implementations that accept code_challenge_method=plain even when only S256 is supposed to be supported, because the validation logic checks that a method value was provided but does not enforce which values are permitted.
State Parameter Abuse and CSRF
The state parameter exists to prevent cross-site request forgery attacks against the OAuth callback. The client generates a random, unguessable value, binds it to the user's session, sends it in the authorization request, and verifies it matches when the callback is received. An authorization server that returns a predictable or static state value — or an application that does not verify it — is vulnerable to login CSRF.
Login CSRF in OAuth allows an attacker to force a victim to associate the attacker's identity with the victim's account. The attack flow is:
- Attacker initiates an OAuth authorization flow for their own account.
- Attacker receives an authorization code bound to their identity.
- Attacker does not complete the token exchange — they capture the callback URL instead.
- Attacker tricks the victim into visiting the captured callback URL (phishing, iframe injection, etc.).
- Victim's browser completes the OAuth flow, linking the attacker's identity to the victim's session.
- Attacker can now authenticate as the victim by using their own credentials through the linked account.
When testing, we verify state validation is enforced by replaying a callback with a modified or missing state value and confirming the application rejects it with an error rather than completing the login.
Scope Escalation and Insufficient Scope Validation
OAuth scopes define what actions a token is authorized to perform. Scope enforcement failures allow a token issued for narrow permissions to be used for broader operations. We find this in two primary patterns.
The first is that the authorization server issues a token with broader scopes than the client requested. This happens when the server grants all scopes associated with the client application rather than the subset explicitly requested. An application that only requests read:profile receives a token that also carries write:profile admin:users.
The second pattern is that the resource server does not verify the scopes present in the token. The token might correctly contain only read:profile, but the API endpoint that modifies user data does not check whether the presenting token carries a write scope. This is an authorization enforcement failure at the resource server level rather than the authorization server level, and it is extremely common in microservice architectures where each service team independently implements token validation.
We test scope enforcement by obtaining a token with minimal scopes and attempting all write, delete, and administrative operations — including operations in other resource domains (user management, billing, configuration) that the same identity provider issues tokens for.
Token Leakage via Browser History and Log Files
Beyond referrer header leakage, access tokens appear in several other unintended locations:
- URL parameters in API requests. Some implementations pass the access token as a query parameter (
?access_token=...) rather than in the Authorization header. Every proxy, CDN, load balancer, and web server access log then records the token in plaintext. - Error pages. Applications that reflect the full request URL in error messages expose any token present in query parameters to users, server logs, and monitoring tools.
- Browser history. Tokens in URL fragments are not sent to servers but are stored in browser history and accessible to JavaScript on the same origin via
history.pushStatemanipulation. - Postmessage cross-origin communication. SPAs that use
postMessageto pass tokens between frames can expose them to other frames if the target origin is not strictly validated.
Testing for query-parameter token transmission is straightforward — review all API requests issued by the application and check for access_token or bearer values outside the Authorization header.
Defensive Guidance
The following controls address the attack classes described above. They are ordered by implementation priority.
Authorization Server Configuration
- Require exact match validation for
redirect_uri— no prefix matching, no wildcards, no path normalization before comparison. - Mandate PKCE with
S256for all public clients. Disable theplainchallenge method. For confidential clients, PKCE provides defense in depth and should be required even when a client secret is present. - Disable the implicit flow (
response_type=token). Use authorization code with PKCE for all SPA and mobile clients. - Issue authorization codes with a maximum validity of 60 seconds and enforce single-use semantics — reject any code presented after it has already been exchanged or after expiry.
- Enforce issued scope equal to requested scope. Do not grant scopes the client did not explicitly request.
Client Application Configuration
- Serve OAuth callback pages with
Referrer-Policy: no-referrer. Remove all third-party resource loads (analytics, fonts, pixels) from callback routes. - Process and invalidate the authorization code immediately upon receipt, before rendering any page content or loading external resources.
- Generate cryptographically random state values (minimum 128 bits of entropy) per authorization request and verify them strictly on callback receipt.
- Store access tokens in memory (JavaScript variables) rather than
localStorage,sessionStorage, or cookies accessible to JavaScript. Use HttpOnly, Secure, SameSite=Strict cookies only for session tokens backed by server-side session management. - Always send access tokens in the
Authorization: Bearerheader — never as URL query parameters.
Resource Server Configuration
- Validate token signature, issuer, audience, expiry, and scope on every request. Do not assume that a valid token carries implicit permissions beyond its declared scopes.
- Enforce scope checks at the endpoint level, not just at the gateway. A gateway-level scope check does not protect against lateral movement between services on internal networks.
- Implement token revocation and honor revocation events from the authorization server promptly. Short-lived tokens (15 minutes or less) limit the window of exposure for any leaked token.
Subdomain Hygiene
- Audit all DNS records for CNAME entries pointing at cloud provider endpoints. Verify each target resource is still provisioned and owned by your organization.
- Remove DNS records for decommissioned services before or at the time the underlying resource is deleted — not after.
- Do not register wildcard subdomains as OAuth redirect URIs unless every possible subdomain is under strict organizational control.
Frequently Asked Questions
Is PKCE only required for mobile and SPA clients?
RFC 9700 recommends PKCE for all OAuth clients, including confidential server-side clients. For confidential clients, PKCE provides an additional layer of protection against authorization code interception attacks even when a client secret is in use. There is no performance or complexity penalty significant enough to justify omitting it.
Does using HTTPS protect against redirect URI attacks?
HTTPS protects the token value in transit. It does not prevent an attacker from registering a malicious server at a domain that passes weak redirect URI validation. The validation logic on the authorization server must be correct regardless of transport security.
How do we test for open redirect chains in OAuth?
We map all open redirect endpoints on the application domain first, then attempt to use each as the redirect_uri value in an OAuth authorization request targeting the application's own authorization server or a third-party identity provider the application integrates with. Open redirects on trusted domains are high-priority findings in OAuth-enabled applications precisely because of this chaining risk.
What is the impact of a compromised implicit flow token vs. an authorization code?
An intercepted authorization code requires an additional step — exchange at the token endpoint — and can be protected by PKCE binding. An access token obtained via the implicit flow is immediately usable against resource servers for its full lifetime with no further validation steps. The implicit flow token is the more immediately dangerous finding.
Should we audit OAuth implementations in third-party libraries?
Yes. Library defaults vary widely. We have found production deployments where OAuth client libraries defaulted to state parameter generation that was insufficiently random, or that accepted both plain and S256 PKCE methods without restricting to the stronger option. Review library configuration options explicitly and do not rely on secure defaults.