odnd.com

Where OAuth Goes Wrong in Real APIs: A Step-by-Step Walkthrough


Most OAuth bugs I find in API assessments aren't exotic crypto failures. They're boring validation gaps: a redirect URI matched with a prefix check, a state value nobody compares, an API that accepts any signed token from the right issuer.

To make this concrete, I had a short animation built of the authorization code flow with PKCE, where a user connects a "Photo App" that asks for read:profile and write:posts:

Animated OAuth 2.0 authorization code flow with PKCE where the user grants only read:profile, the API returns 403 insufficient_scope for write:posts, and the client refreshes its access token

Starting the flow: PKCE, state, and the authorization request

In steps 1 through 3 the client generates a random code_verifier, hashes it into a code_challenge with S256, picks a random state, and sends the browser to /authorize with its redirect_uri and requested scopes.

PKCE (RFC 7636, Proof Key for Code Exchange by OAuth Public Clients) makes a stolen authorization code useless without the verifier. RFC 9700, Best Current Practice for OAuth 2.0 Security, recommends it for confidential clients too, and the OAuth 2.1 draft makes it the default. Use S256, not plain.

The redirect_uri is where I'd look first. A common pattern in API pentests is an authorization server that accepts anything starting with the registered URL, or any subdomain. Pair that with an open redirect on the client and the code lands with the attacker. Register full URIs and compare them as exact strings.

Ask only for the scopes you need. Anything "just in case" widens the blast radius of every token.

Consent and the callback

In step 4, my favorite part, the user unchecks write:posts and grants only read:profile. Clients have to cope with getting less than they asked for.

In step 5 the server redirects back with a short-lived, single-use code and the same state, and the client confirms it matches what it stored for this session. Skip that and an attacker can complete the flow with their own code in a victim's browser, tying the victim's session to the attacker's account. (In OpenID Connect, the ID token's nonce handles replay, though the video doesn't cover it.)

Exchanging the code and handling tokens

Steps 6 and 7 happen on the back channel. The client posts the code and code_verifier to /token, and the server checks the hashed verifier against the challenge. It should also reject reused codes and confirm the code was issued to this client and redirect URI.

The response carries a short-lived access token, a refresh token, and scope: read:profile. Read that field instead of assuming you got everything. And think about storage: tokens in localStorage are one XSS away from exfiltration, which is why I like a backend-for-frontend that keeps them server-side.

The resource server has to do its job

Steps 8 and 9 are where plenty of APIs quietly fail. The API checks the signature against the issuer's key, the exp claim, and that aud is api.example.com, then enforces scope per endpoint. GET /me gets a 200. POST /posts gets a 403 with insufficient_scope, the error code from RFC 6750.

Lots of APIs validate the signature and stop. Without an audience check, a token minted for another service at the same issuer works fine against yours. Scope isn't the whole story either. A token with read:profile shouldn't read someone else's profile because an ID in the path changed.

Refreshing without widening access

The last step jumps an hour ahead. The access token has expired, so the client sends its refresh token to /token and gets a new access token with the same or narrower scope. Getting write:posts back would take new consent.

The video notes that refresh tokens may rotate. For public clients, RFC 9700 says they should rotate or be sender-constrained. Rotation lets the server spot reuse of an old token and revoke the whole family. Sender-constraining binds tokens to a key the client holds, via DPoP (RFC 9449) or mutual TLS (RFC 8705). Either is worth it for high-value APIs.

What the animation leaves out on purpose

You won't see the implicit grant or the resource owner password grant in the video. Implicit put access tokens in the URL fragment, and the password grant hands user credentials straight to the client. RFC 9700 says not to use either, and the OAuth 2.1 draft drops both. If either is still in production, I'd start there.