~/learn / authorization / oauth
OAuth 2.0 Explained
How OAuth lets one app act on your behalf at another without your password, the authorization code flow and PKCE explained mechanically, how redirect and state attacks steal accounts, and the misconfigurations testers find.
When an app offers “connect your Google account” or “sign in with GitHub,” it is using OAuth. OAuth solves one problem: letting an application access your data at another service without ever seeing your password. The key word is delegation. OAuth grants limited, revocable access; it does not, by itself, prove identity. Identity is layered on top by OpenID Connect, covered in single sign-on.
The four roles
| Role | Who it is | Example |
|---|---|---|
| Resource owner | You, the user | You |
| Client | The app that wants access | A calendar app |
| Authorization server | Issues tokens after you approve | Google’s login and consent screen |
| Resource server | Holds the data, accepts tokens | The Google Calendar API |
OAuth is a textbook confused-deputy setup: the client is a deputy you grant limited authority. Every attack below is a way to redirect that authority to the wrong party.
The authorization code flow with PKCE
This is the flow to expect for nearly everything today, including single-page and mobile apps.
- The client generates a random code verifier (a high-entropy string) and derives a code challenge from it:
challenge = base64url(SHA-256(verifier)). - The client sends your browser to the authorization server with its
client_id, aredirect_uri, the requestedscope, a randomstate, and thecode_challenge. - You log in at the authorization server, not at the client, and approve the scopes.
- The authorization server redirects your browser back to the registered
redirect_uriwith a short-lived authorization code and the samestate. - The client verifies
statematches, then exchanges the code plus the original code verifier, server to server, for an access token. - The client calls the API with
Authorization: Bearer <access token>.
Two pieces do the security work, and understanding exactly what each prevents is the whole topic.
statebinds the redirect back to the request your browser started. Without it, an attacker can complete their own authorization, capture their code, and trick your browser into redeeming it, logging you into the attacker’s account (login CSRF). With a randomstatethe client checks, the forged response does not match and is rejected.- PKCE proves that whoever redeems the code is whoever began the flow. The authorization server remembers the
code_challengeand, at token exchange, requires the matchingcode_verifier. Since SHA-256 is one-way, an attacker who intercepts the code (on a mobile device, through a logged redirect, through a malicious app registered for the same URL scheme) still cannot produce the verifier, so the stolen code is worthless.
The redirect_uri attack, step by step
The redirect_uri is where the code is delivered, which makes it the prize. If the authorization server validates it loosely, the attacker wins:
- Open redirect on the client. The attacker registers
https://client.test/callback, then abuses an open redirect on that domain so the code bounces onward toattacker.test. Exact-match registration of the full path blunts this; eliminating open redirects on the client domain closes it. - Substring or prefix matching. A server that accepts any URI “starting with” the registered value accepts
https://client.test.attacker.test/. Validation must be exact string equality against a registered allowlist, not prefix, not “same domain,” not regex. - Extra parameters or fragments. Appending
?x=or#to slip past a naive check. Exact match again.
Because the code alone is often enough to log in as the victim at a client that lacks PKCE, redirect validation plus PKCE together are what make the flow safe.
Other flows you will meet
- Client credentials. Machine to machine, no user. A backend gets a token to call another service on its own behalf.
- Device authorization. For TVs and CLIs: the device shows a code, you approve on your phone.
- Refresh tokens. Long-lived tokens that mint new access tokens without re-login. They must be stored carefully, bound to the client, and rotated so that a stolen refresh token is detected (reuse of a rotated token signals theft).
- Implicit flow and password grant. Both deprecated. RFC 9700 (OAuth 2.0 Security Best Current Practice) says do not use them; the implicit flow returned tokens in the URL fragment where they leaked, and the password grant hands the client your actual password, defeating the point. Finding either in an assessment is worth reporting.
Scopes limit, they do not authorize
A scope such as calendar.read limits what a token may be used for. It does not decide whether this user may read this specific calendar. The resource server must still check object-level ownership on every request. A valid, correctly-scoped token is necessary, not sufficient, which is the same lesson as everywhere else in authorization.
What testers look for
Every one of these is testable on PortSwigger’s free OAuth labs.
| Weakness | Why it matters | Fix |
|---|---|---|
Loose redirect_uri validation |
Codes or tokens diverted to an attacker URL | Exact-match against registered URIs |
Missing or unchecked state |
Login CSRF: victim logged into the attacker’s account | Generate and verify state |
| No PKCE for public clients | Intercepted codes can be redeemed | Require PKCE for every client |
| Tokens in URLs | Leak via logs, history, Referer |
Code flow only, never tokens in query strings |
| Over-broad scopes | One compromised client reaches everything | Request the minimum scope |
| Long-lived, unrotated refresh tokens | A single leak grants lasting access | Rotation, expiry, client binding |
Field notes: stealing the authorization code
Most OAuth account takeovers come down to redirecting the code to a URL you control. The redirect_uri validation is the battleground, and lazy matching loses:
| Server’s matching rule | Bypass |
|---|---|
| Prefix match | redirect_uri=https://app.test.attacker.com |
| Substring / contains | redirect_uri=https://attacker.com/app.test |
| Allows subpaths | an open redirect on the real app: .../callback?next=//attacker.com |
| Allows subdomains | any XSS or takeover on a sibling subdomain |
| Ignores fragment/params | append &redirect_uri=... a second time (parameter pollution) |
Even with a locked redirect_uri, the code can leak through the Referer header (if the callback page loads attacker-controlled resources) or through an open redirect in the allowed path. Once you have a victim’s code, you redeem it at the token endpoint and you are them.
Field notes: state, PKCE, and scope
- Missing
stateis CSRF on the login flow: you start an OAuth flow, capture yourcode, and trick the victim’s browser into completing the callback with it, logging the victim into your account (or, in the “login CSRF” direction, linking your social account to their session). Always check whetherstateis present, unpredictable, and actually validated (remove it and see if the flow still completes). - PKCE downgrade: a public client should send
code_challenge. If the server still issues a token when you omit it, PKCE is not enforced and a stolen code is enough. Try strippingcode_challengeandcode_verifier. - Scope does not authorize. A token minted for
readscope that the API honors for a write is a classic broken-authorization bug. Request minimal scope, then try privileged actions. response_type=token(implicit flow) puts the token in the URL fragment where it leaks through history, Referer, and redirects. Its presence on a modern app is itself a finding; prefer the code flow with PKCE.
The highest-impact OAuth bug is full account takeover by email-claim trust: if the app creates or links accounts by the email in the provider’s profile and you can control an unverified email at an attacker-run or misconfigured IdP, you log in as the victim. Only test flows you are authorized to.
Takeaway
OAuth moves the password prompt to a trusted authorization server and hands the client a limited, revocable token. Almost no real-world OAuth bug is in the protocol itself. They are in how a specific client or server validates redirects, state, PKCE, and tokens, which is exactly where you look.