Skip to content
pwnsy
web-securityintermediate#oauth#web-security#authentication#api-security#appsec

OAuth Attacks: redirect_uri Manipulation, Codes & CSRF

How OAuth 2.0 attacks work: redirect_uri manipulation, stolen authorization codes, CSRF on the login flow, and how PKCE defends it.

OAuth 2.0 is the machinery behind "Sign in with" buttons and the delegated access that lets one app act on your behalf in another. It is everywhere, and its security depends on a short sequence of redirects going exactly right. When any step is loose, the same flow that grants access to the rightful user can be steered to grant it to an attacker.

This guide covers the OAuth attacks that show up most often in real assessments: redirect_uri manipulation, stolen authorization codes, and cross-site request forgery on the flow itself. Each one exploits a specific gap, and each has a specific fix. For the mechanics of the flow from the start, see OAuth 2.0 Explained.

The flow in one paragraph

In the authorization code flow, your app sends the user to the authorization server with a client_id, a redirect_uri, and a state. The user logs in and approves. The authorization server redirects the browser back to the redirect_uri carrying a short-lived authorization code. Your app's backend then exchanges that code, plus its client credentials, at the token endpoint for an access token. The code travels through the user's browser. The token does not. Everything below is about what can go wrong with that code in transit.

redirect_uri manipulation

The redirect_uri tells the authorization server where to send the code. If the server does not match it strictly against a pre-registered value, an attacker can point it somewhere they control.

Suppose the legitimate callback is https://app.example/callback. If the authorization server accepts anything on the domain, or matches only a prefix, an attacker can craft a request with a redirect_uri such as https://app.example.attacker.com/callback or https://app.example/callback/../../evil. The victim logs in normally, and the authorization server dutifully sends the code to the attacker's endpoint. The attacker then exchanges it and lands inside the victim's linked account.

Common weaknesses that enable this:

  • Wildcard or subdomain matching on the registered redirect.
  • Prefix matching, where the check only confirms the URL starts with an allowed value.
  • Open redirects on the legitimate domain, where an allowed callback bounces the code onward to an attacker. See Open Redirect Explained for how that chain forms.
  • Accepting extra path segments, fragments, or query parameters that change the effective destination.

The defense is unglamorous and absolute: exact string matching against a registered redirect_uri, no wildcards, no prefixes, no normalization surprises.

Exact match or nothing

The single most common OAuth flaw is a permissive redirect_uri check. If the authorization server allows any wildcard, subdomain, or partial match, an attacker can capture authorization codes for real users. Register the full callback URLs and compare them byte for byte.

Stolen authorization codes

Even with a correct redirect, the authorization code is a bearer of access while it lives. Anyone who obtains a valid, unused code can attempt to redeem it. Codes leak through several channels:

  • Referrer headers, if the callback page loads external resources and the code is in the URL.
  • Browser history and logs, proxies, and any intermediary that records full URLs.
  • Open redirects and injection on the callback page that expose the query string.
  • A malicious app on the same device intercepting a redirect to a custom URL scheme, the classic mobile problem.

Two rules limit the value of a stolen code. First, codes must be single-use: the token endpoint redeems a code once and refuses it forever after, so a race between attacker and victim ends the moment either redeems it. Second, codes must be short-lived, giving an attacker a narrow window. Neither rule prevents interception on its own, which is what PKCE is for.

CSRF on the OAuth flow

Cross-site request forgery against OAuth comes in two shapes, and both are prevented by the state parameter.

In the first, an attacker starts their own OAuth flow, obtains a valid authorization code for their account, and then tricks the victim's browser into completing the callback with that code. If the app has no state check, the victim's session gets linked to the attacker's account. Anything the victim then saves, a payment method, private notes, uploaded files, lands in an account the attacker controls.

In the second, the attacker forces the victim through the attacker's login, quietly connecting the victim's session to the attacker's identity at the provider.

The state parameter closes both. Your app generates an unguessable value, ties it to the user's session, sends it in the authorization request, and verifies on the callback that the returned state matches. A forged callback carries no valid state, so it is rejected. This is the same anti-CSRF pattern used elsewhere on the web, applied to the login handshake.

Leaking tokens through the front channel

The authorization code flow keeps tokens off the browser by exchanging the code on the backend. An older pattern, the implicit flow, skipped that step and returned the access token directly in the redirect URL fragment. That put the token everywhere a URL goes: browser history, referrer headers, server logs, and any script on the callback page. The implicit flow is now discouraged for exactly this reason, and new clients should use the authorization code flow with PKCE instead.

The general lesson holds beyond implicit flow. Any time a token or code lands in a URL, treat it as exposed. Keep the response type set to code, complete the exchange server-side, and make sure the callback page does not load third-party scripts or resources while sensitive values sit in the address bar.

Account pre-linking and scope abuse

Two more moves are worth knowing. In a pre-account-linking attack, an attacker connects their own social login to a target account before the victim ever does, so when the victim later signs in through that provider they land in an account the attacker already controls. Requiring a fresh, authenticated confirmation before linking a new identity provider closes this.

Scope abuse is the other. If your app requests broad scopes it does not need, a stolen token grants the attacker more than the feature ever required. Request the narrowest scopes that make the feature work, and have your resource server enforce that a token is only accepted for the scopes it was actually granted.

PKCE: binding the code to its client

Proof Key for Code Exchange, defined in RFC 7636, was designed for mobile and single-page apps that cannot keep a client secret, and it is now recommended for every OAuth client.

The mechanism is simple. Before starting the flow, the client generates a random code_verifier and sends a hashed version, the code_challenge, in the authorization request. When it later exchanges the code at the token endpoint, it must present the original code_verifier. The authorization server hashes it and confirms it matches the challenge from the start of the flow. A stolen code is useless to an attacker who does not also hold the matching verifier, which never left the legitimate client.

PKCE is what turns code interception from a working attack into a dead end. It does not replace strict redirect matching or state; it layers on top of them.

Attack summary

AttackWhere it strikesRoot causePrimary defense
redirect_uri manipulationAuthorization requestLoose redirect matchingExact-match registered URIs
Stolen authorization codeCode in transitCode leaks, replayable codeSingle-use short-lived codes, PKCE
CSRF on the flowThe callbackMissing state bindingUnguessable state, verified on return
Code interceptionMobile or SPA redirectNo client-to-code bindingPKCE on every client

Stealing and replaying these access tokens maps to Steal Application Access Token (T1528) on MITRE ATT&CK.

A redirect_uri capture, step by step

It helps to trace a single attack from start to finish, because the individual weaknesses only become dangerous when they chain. Picture an app whose real callback is https://app.example/callback, registered with a provider that matches redirect values by prefix rather than exactly.

The attacker starts by reading the app's public authorization request. Anyone can see it, because it travels in the browser as a plain URL when a user clicks the sign-in button. From it the attacker learns the client_id, the scope list, and the shape of the redirect_uri. Nothing here is secret, and that is the point: the front channel of OAuth is designed to be visible.

Next the attacker crafts a malicious authorization URL. It reuses the legitimate client_id and scope, but swaps the redirect to a value the loose matcher still accepts, for example https://app.example.attacker.com/callback if the check only confirms the string begins with https://app.example. The attacker delivers this URL to the victim through a phishing message, a malicious ad, or a link on a page the victim already trusts.

The victim clicks. Because they are already logged in at the provider, or because they log in now, the provider sees a valid session and a client_id it recognizes. It generates an authorization code and redirects the browser to the attacker's chosen callback. The victim never sees a warning, because from the provider's view nothing is wrong: a known client asked for a code and got one.

The code now lands on the attacker's server. If the app used no PKCE and holds a confidential client secret, the attacker cannot complete the exchange alone, but a code that reaches an attacker-controlled endpoint on a domain the app also serves can often be replayed into the app's own session. Where the client is public and there is no PKCE, the attacker exchanges the code directly and receives tokens scoped to the victim. Either way the strict controls were bypassed at a single point: the redirect match.

Every link in that chain has a named fix. Exact redirect matching breaks step two. PKCE breaks the final exchange. A verified state breaks the forged callback variant. The attack works only when several of those are missing at once, which is why layered controls matter more than any single one.

Detection signals

Most OAuth abuse is quiet by design, because the attacker rides the legitimate flow. Detection therefore leans on the authorization server's logs and on watching for shapes that a normal client never produces.

  • Authorization requests with unregistered or near-miss redirect values. A request whose redirect_uri almost matches a registered callback, differing by a subdomain, an extra path segment, or a trailing parameter, is a strong signal of a redirect probe. Log every rejected redirect and alert when a known client_id starts sending unfamiliar ones.
  • Codes redeemed from unexpected network locations. An authorization code issued to one client and then exchanged from an IP range or autonomous system the client never uses points to interception. Correlate the address that received the code with the address that redeems it.
  • Repeated single-use code failures. A spike in token-endpoint rejections for already-redeemed codes can mean an attacker is racing a victim to redeem, or replaying captured codes. Treat a rise in second-redemption attempts as an active-abuse indicator.
  • Callbacks arriving with absent or mismatched state. If your application logs callbacks, a burst of requests carrying no state, or a state that does not tie to any live session, is the classic footprint of a CSRF attempt against the flow.
  • Authorization requests for unusually broad scopes. A client that normally asks for a narrow scope suddenly requesting everything can indicate a tampered or attacker-authored request.
Log the front channel too

Teams often instrument the token endpoint and ignore the authorization endpoint, which is where redirect and state abuse first shows. Record every authorization request with its client_id, redirect_uri, scope, and state presence, and every redirect rejection. The earliest evidence of an OAuth attack is a rejected or near-miss redirect, long before any token is issued.

Common mistakes and misconceptions

A few beliefs recur and each one leaves a real gap.

"PKCE is only for mobile and single-page apps." PKCE was born there because those clients cannot hold a secret, but the current guidance applies it to every client. A confidential web app still benefits, because PKCE defends the code exchange independently of the client secret and closes interception paths a secret alone does not.

"A client secret makes the flow safe, so redirect matching can be loose." The secret protects the token exchange, and the redirect governs where the code is delivered. A loose redirect leaks the code to the attacker before the exchange ever happens, and in many real deployments the code alone is enough to hijack a session. The two controls defend different steps and neither substitutes for the other.

"state and PKCE do the same thing, so one is enough." They solve different problems. The state parameter binds the callback to the browser session that started the flow, which defeats CSRF. PKCE binds the code to the client instance that requested it, which defeats interception. Dropping either one reopens the attack it was there to stop.

"The implicit flow is fine if the site uses HTTPS." Transport encryption does not keep a token out of browser history, referrer headers, or scripts running on the callback page. The token lands in the URL fragment regardless of TLS. The authorization code flow with PKCE is the current recommendation for exactly this reason.

"Validating the redirect once at registration is enough." Registration records the allowed values, but the authorization server must compare the incoming redirect_uri against them on every single request, as an exact string. A check that runs only at registration time and trusts the request thereafter is no check at all.

The flows compared

The grant type an app chooses decides how much front-channel exposure it carries. The table sets the common options side by side.

FlowToken deliveryFront-channel exposureCurrent standing
Authorization code with PKCEBackend exchange, token never in browserOnly the short-lived code, bound by PKCERecommended for all clients
Authorization code without PKCEBackend exchangeCode exposed to interceptionAcceptable only with a confidential client and exact redirect matching, PKCE still preferred
ImplicitToken returned in URL fragmentFull token in the browser URLDiscouraged, replaced by code plus PKCE
Resource owner passwordApp collects the provider passwordApp sees the raw passwordDiscouraged, defeats delegation

The trend across the table is toward keeping secrets off the browser and binding what does travel through it. Where an older flow exposed a token or a password to the front channel, the modern flow exposes only a code that PKCE renders useless to a thief.

How OAuth security evolved

The early OAuth 2.0 framework, RFC 6749, described several grant types and left many security choices to implementers. The implicit flow returned tokens straight to the browser, which suited the single-page apps of the time but scattered tokens through the front channel. The resource owner password grant let an app collect a user's provider password directly, which defeated the delegation the framework was meant to provide.

Over the following years the guidance tightened as attacks accumulated in the field. PKCE arrived in RFC 7636 to protect public clients, then broadened into a recommendation for all clients. The OAuth 2.0 Security Best Current Practice consolidated the hard lessons: exact redirect matching, PKCE everywhere, no implicit flow, no password grant. OAuth 2.1, still consolidating at the time of writing, folds those practices into the baseline so that the safe path is the default rather than an add-on. The direction of travel is consistent: remove the flexible options that turned out to be footguns, and make the code-plus-PKCE flow the one obvious way to build.

Frequently asked questions

Does PKCE replace the state parameter? No. PKCE stops a stolen code from being exchanged by anyone but the original client, and state stops a forged callback from being accepted by the browser session. Use both. They cover different attacker moves and neither one covers the other's.

Is the authorization code secret? Treat it as sensitive but short-lived. It is a bearer of access while it is unused and unexpired, so anyone who captures a live code can attempt to redeem it. Codes must be single-use and expire quickly, and PKCE ensures that even a captured code is useless without the matching verifier.

Can an attacker do damage with only a client_id? The client_id is public and identifies the app, so knowing it is expected. On its own it grants nothing. The risk appears when the attacker combines a known client_id with a loose redirect or a missing PKCE check, which is why those server-side controls carry the weight.

Why is exact redirect matching preferred over allowlisting patterns? Patterns invite mistakes. A wildcard, a prefix, or a normalization rule almost always admits a value the author did not intend, such as a subdomain an attacker can register or a path that traverses elsewhere. Exact string comparison against fully registered URLs removes the entire category of matching bugs.

Is OAuth the same as OpenID Connect? They are related but distinct. OAuth 2.0 grants delegated access to resources. OpenID Connect is an identity layer built on top of OAuth that adds an ID token proving who the user is. The attacks here target the shared OAuth machinery, so they apply to OpenID Connect flows as well.

What is the single most impactful control? Exact redirect matching combined with PKCE. Redirect matching keeps the code from being delivered to an attacker, and PKCE keeps a captured code from being exchanged. Those two together close the most common and most damaging paths.

How to defend the OAuth flow

  1. Match redirect_uri exactly. Register full callback URLs and compare them as exact strings. Drop wildcard, subdomain, and prefix matching entirely.
  2. Require PKCE for every client. Public or confidential, PKCE binds the code to the client that started the flow and neutralizes interception.
  3. Send and verify state. Generate an unguessable value per request, tie it to the session, and reject any callback whose state does not match.
  4. Make codes single-use and short-lived. Redeem each code once, then reject it. Keep the lifetime to a minute or two.
  5. Keep tokens off the front channel. Exchange the code for tokens on the backend so access tokens never appear in a browser URL or history.
  6. Fix open redirects on your domain. An allowed callback that forwards a code to an arbitrary URL undoes the strict matching above.
Track the flaws attackers actually use

Authentication and authorization flaws are among the most exploited classes in modern breaches. Our Exploit Intelligence dashboard shows which vulnerability classes are under active exploitation, so you can prioritize the OAuth and identity fixes that matter now.

OAuth security is a chain of small guarantees. Match the redirect exactly, bind the code with PKCE, verify the state, and burn the code after one use. Each control covers a different attacker move, and together they leave the authorization code with nowhere to be stolen and nothing to be replayed against.

Sources & further reading

Sharetwitterlinkedin

Related guides