OAuth 2.0 Security: Common Implementation Flaws and How to Exploit Them
OAuth 2.0 is not a protocol that is difficult to understand. The specification is readable, the flows are well-documented, and countless reference implementations exist. Yet OAuth deployments remain a consistent source of authorization vulnerabilities across web applications of every size.
The problem is not the protocol itself. It is the gap between reading the specification and implementing every security requirement it describes — especially the ones that appear optional until they are exploited.
What OAuth 2.0 Is Actually Doing
OAuth 2.0 is an authorization delegation framework. It allows a user to grant an application access to resources on a third-party service without sharing their credentials with that application. The user authenticates directly with the third-party service, which issues tokens that the application can use to access specific resources on the user's behalf.
The authorization code flow — the most secure and widely recommended variant — works in four steps:
- The client redirects the user to the authorization server with a request for specific scopes and a redirect URI where the response should be sent.
- The user authenticates and approves the access request. The authorization server redirects back to the client's redirect URI with a short-lived authorization code.
- The client exchanges the authorization code for an access token and optionally a refresh token, using its client credentials to authenticate to the token endpoint.
- The client uses the access token to make authorized requests to the resource server.
Each step in this flow has failure modes. Each failure mode has a security consequence.
Missing or Weak State Parameter Validation
The state parameter exists to prevent cross-site request forgery against the OAuth flow itself.
Before initiating an authorization request, the client generates a random, unguessable value, stores it in the user's session, and includes it as a query parameter in the authorization URL. When the authorization server redirects back to the client, it includes the same state value. The client reads the returned state, compares it to what it stored, and rejects the response if they do not match.
When this check is missing, the flow is vulnerable to CSRF. The attack works as follows:
- The attacker initiates an OAuth flow to link their own account on the target application to a legitimate external service.
- The attacker captures the authorization URL the target application generated but does not complete the flow — they stop before the redirect back.
- The attacker tricks a victim into clicking a link (or loading an image, or any mechanism that causes the browser to fetch a URL) that points to the OAuth callback URL with the attacker's authorization code and no state.
- The victim's browser completes the callback. If the application does not validate state, it processes the attacker's authorization code using the victim's session — linking the attacker's external account to the victim's application account.
The attacker can now log into the target application using their own external credentials and access the victim's account.
This attack requires no token theft, no session hijacking, and no man-in-the-middle position. It exploits the absence of a single comparison.
State parameter validation is listed as a security requirement in RFC 6749. It is frequently skipped because it is easy to overlook and has no visible effect on the normal flow.
Redirect URI Validation Failures
The authorization server is responsible for validating that the redirect_uri in each authorization request matches a URI registered by the legitimate client. If this validation is weak or absent, an attacker can redirect authorization codes to their own endpoint and exchange them for tokens.
Common Bypass Techniques
Suffix-only matching. Some servers validate that the redirect_uri ends with the registered domain, checking whether the URI contains the expected host as a suffix rather than an exact match. This check passes for https://evil.com?x=legitimate-app.com if the validation looks for legitimate-app.com as a substring.
Path prefix matching. If the server validates that the URI starts with the registered path, an attacker can append additional path components. A registered URI of https://app.example.com/callback passes a prefix check for https://app.example.com/callback/../../attacker.
Open redirects on the client domain. Even when redirect_uri is validated exactly, if the legitimate callback page contains an open redirect that can be exploited to forward the request, the authorization code can be redirected after it reaches the legitimate domain. The pattern is to redirect the user to the callback URL with a next parameter pointing to an attacker-controlled server, then exploit the open redirect to forward the code.
Wildcard subdomain matching. Some servers allow wildcard domains such as *.example.com. An attacker who can register or take over any subdomain of example.com — including via subdomain takeover of a defunct service — receives authorization codes for the entire domain space.
The correct implementation is exact string matching against a whitelist of pre-registered URIs with no wildcards, no suffix matching, and no path prefix matching. Any deviation from exact matching creates a bypass surface.
Authorization Code and Token Leakage
Referer Header Exposure
When the authorization server redirects to the callback URL with the authorization code as a query parameter, that URL appears in browser history and is sent as the Referer header when the callback page loads external resources.
If the callback page includes a third-party script, a tracking pixel, an embedded font, or any other cross-origin resource, the browser sends the full callback URL — including the authorization code — as the Referer header in each of those requests. The operators of those third-party services can read the authorization code from their access logs.
The standard mitigation is to use the Referrer-Policy: no-referrer header on the callback page and to exchange the authorization code for a token as quickly as possible. Short code lifetimes (under 10 minutes, often under 60 seconds) limit the window during which a leaked code is useful.
Server Access Log Exposure
Authorization codes in query parameters appear in server access logs. If the application logs the full request URI, authorization codes appear in log files that may be accessible to developers, operations teams, log aggregation services, or anyone with access to the logging infrastructure. Short code expiry times reduce this exposure but do not eliminate it.
Implicit Flow Fragment Exposure
The implicit flow, now deprecated but still present in legacy systems, returns access tokens directly as URL fragments in the redirect. Fragment values are not sent to servers in HTTP requests, but they are stored in browser history and accessible to JavaScript running on the callback page. Any cross-site scripting vulnerability on the callback page can extract the access token from window.location.hash.
The access token, unlike an authorization code, is a long-lived credential. There is no equivalent of server-side code expiry for access tokens that have already been issued. A leaked access token remains valid until it expires or is revoked.
PKCE: When It Protects and When It Fails
PKCE (Proof Key for Code Exchange) was introduced to protect OAuth flows in public clients — mobile applications and single-page applications that cannot securely store a client secret — against authorization code interception attacks.
The mechanism is straightforward: the client generates a random code_verifier, computes code_challenge = BASE64URL(SHA256(code_verifier)), and includes the challenge in the authorization request. When exchanging the code for a token, the client includes the original code_verifier. The server verifies that SHA256(code_verifier) matches the stored challenge. An attacker who intercepts the authorization code cannot exchange it without the verifier.
PKCE provides strong protection when both the client implements it correctly and the server enforces it. The failure modes are on the server side:
Server does not require PKCE. If the server accepts token exchange requests that omit the code_verifier for flows that were initiated with a code_challenge, PKCE provides no protection. An attacker who intercepts an authorization code simply omits the verifier parameter, and the server accepts the request. This is the most common PKCE implementation failure.
Server accepts plain method. PKCE supports two challenge methods: S256 (SHA-256 hash) and plain (the verifier is the challenge). With plain, the challenge and the verifier are identical. An attacker who can observe the authorization request — which is sent as a URL and appears in logs and Referer headers — sees the code_challenge and therefore has the code_verifier needed to exchange the code. Servers should reject plain method requests or not advertise it as supported.
PKCE applied to confidential clients without additional checks. Confidential clients can authenticate to the token endpoint using a client secret. Adding PKCE to a confidential client flow provides defense in depth but does not change the fundamental security model for code theft, since the client secret is already required.
Token Scope and Privilege Escalation
OAuth tokens carry scopes that define what the token is authorized to access. Scope downgrade and scope escalation vulnerabilities arise when:
The server does not validate that the issued token scope matches the requested scope. An authorization server that issues tokens with broader scopes than were requested allows clients — or attackers — to obtain access beyond what was granted. During testing, replacing the scope parameter value in authorization requests with broader scope values and verifying that the resulting token does not reflect those expanded scopes is a basic check.
Refresh token scope escalation. Some implementations allow a refresh token request to specify a different scope than the original grant. If the server does not validate that the requested scope is within the originally authorized set, an attacker who obtains a refresh token can escalate its privileges without the user's knowledge or re-consent.
Audience validation failure. Access tokens typically include an aud (audience) claim identifying which resource server should accept the token. Resource servers that do not validate the audience claim accept tokens intended for other services. In a system with multiple resource servers sharing the same authorization server, a token issued for a low-privilege service can be presented to a high-privilege service.
Identifying OAuth Vulnerabilities in Practice
When assessing an OAuth implementation, the systematic approach is to work through the authorization request, the callback handling, and the token exchange independently.
Authorization request: Test state parameter handling by replaying authorization callbacks with a state value that differs from the one stored in the session. A compliant implementation rejects the response. An omitted state check accepts it. Test redirect_uri validation by appending path components, adding query parameters, and trying URL-encoded variants to find where the validation breaks.
Callback handling: Check what external resources load on the callback page while the authorization code is present in the URL. Review whether the Referer policy prevents the code from being forwarded. Check whether the code is consumed in the callback handler before any redirects.
Token exchange: Test whether PKCE is enforced by omitting the code_verifier from exchange requests for PKCE-initiated flows. Test scope handling by requesting broader scopes and verifying the issued token does not reflect them.
Token storage and transmission: Verify tokens are not present in URL parameters in production usage. Check that tokens are transmitted only over TLS. Verify that refresh tokens are stored in secure, HttpOnly, SameSite=Strict cookies rather than in localStorage.
Remediation
The requirements for a secure OAuth implementation are well-specified:
- Generate cryptographically random state values with at least 128 bits of entropy; validate them in every callback handler before processing any response parameters
- Validate redirect_uri by exact string match against a pre-registered whitelist; do not accept wildcard entries or partial matches
- Use the authorization code flow with PKCE for all public clients; enforce PKCE verification on the server and reject
plainmethod - Set
Referrer-Policy: no-referreron all callback pages; expire authorization codes within 60 seconds of issuance; bind each code to the client that requested it - Validate token audience and scope on every protected resource endpoint; do not accept tokens whose
audclaim does not match the current service - Store access tokens in memory; store refresh tokens in HttpOnly, SameSite=Strict cookies; never store credentials in localStorage or sessionStorage
The protocol is sound. Every common OAuth vulnerability is a failure to implement a requirement the specification states explicitly. The path from a vulnerable OAuth deployment to a secure one runs through the RFC, not around it.
For a practical example of OAuth-adjacent access control failures in production systems, see the access control case studies.