Skip to content

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.

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

  1. Identify the flow — response_type in 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).
  2. Identify the client type — confidential (server-side, has client_secret) or public (SPA, mobile; no secret, relies on PKCE).
  3. Probe redirect_uri validation — 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.
  4. Probe state — if state is absent, fixed, or not bound to the RP session → login CSRF.
  5. Probe PKCE — if code_challenge is omitted or downgraded to plain → MITM can intercept the code (the original reason PKCE was added in RFC 7636, even for confidential clients — OAuth 2.1 makes PKCE mandatory).
  6. Probe nonce — if nonce is absent or not validated against the id_token’s nonce claim → id_token replay.
  7. Probe id_token — apply W13 attacks (alg=none, RS256→HS256, JWK header, kid traversal).
TechniqueDescriptionDetection Difficulty
redirect_uri permissive*.RP.com match → attacker subdomain (if attacker controls sub or via XSS)Medium — depends on canonicalization
redirect_uri path traversalhttps://RP.com/cb/../evil acceptedMedium — depends on parser
redirect_uri open-redirect chainRP’s own open redirect takes the code to attackerHard — needs open redirect at RP
Login CSRF (state missing/weak)Attacker starts OAuth flow, sends victim the callback URLEasy to fix, easy to miss
PKCE downgrade (S256→plain)MITM modifies the authz request to code_challenge_method=plainMedium — needs network position
PKCE omissionPublic client without PKCE → intercepted code is exchangeableEasy — should be enforced
id_token alg confusionW13 attack against the OIDC id_tokenMedium — library-dependent
id_token replay (nonce missing)Stolen id_token replayed, nonce not checkedEasy if nonce not validated
Implicit flow token leakToken in URL fragment → leaked via Referer, browser historyEasy — implicit flow is deprecated for this reason
Token substitutionRP accepts any token from any issuerMedium — multi-tenant apps
Refresh-token theftStolen refresh_token reused indefinitelyEasy if no rotation
Dynamic client registrationAttacker registers malicious client, gets client_idMedium — depends on AS policy
“Login as victim” account chooserSocial — user clicks “Allow” on attacker’s appNot a bug — UX risk
Open redirect via post_logout_redirect_uriRP’s RP-Initiated Logout endpoint has open redirectMedium — common oversight
Terminal window
# Inspect the authorization endpoint — try redirect_uri variants
REDIR_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 JWKS
curl -s https://auth-server.com/.well-known/openid-configuration | jq .jwks_uri
curl -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 validated
curl -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 enforced
curl -X POST https://auth-server.com/token -d "grant_type=refresh_token&refresh_token=<old>&client_id=ID"
# Probe dynamic client registration
curl -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 templates
nuclei -u https://target-app.com -t ~/nuclei-templates/http/technologies/ -tags oauth,oidc
nuclei -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 redirect
curl -sI "https://target-app.com/logout?post_logout_redirect_uri=https://attacker.com/"

ModSecurity OWASP CRS — no dedicated OAuth rule family. Detection is via:

  • 920220 (Content-Type policy) — catches malformed /token grant 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:

  • /token endpoint responding 200 with grant_type=refresh_token for a token that was already used → no rotation.
  • /authorize endpoint responding 302 to an external redirect_uri → permissive validation.
  • Multiple /authorize requests with different state from same RP session → state not bound.

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 of redirect_uri for the AS, but @attacker.com is 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.

AttackWhat it bypassesDefense
redirect_uri parser differentialExact-match validationPre-register exact URL; reject any non-match
Login CSRFState checkBind state to RP session; PKCE
PKCE downgradecode_challenge_method checkReject plain; enforce PKCE for all flows
Token substitutioniss/aud validationValidate iss == configured AS, aud == RP client_id
Refresh-token replaySingle-use refreshRotate on every use; revoke old; bind to client
nonce replaynonce validationGenerate fresh nonce per flow; check in id_token
Dynamic client registrationRegistration ACLRequire auth on /register; allowlist by IP/org

⚠️ What caught me: A SaaS allowed redirect_uri to be any path under https://app.target.com/oauth/. The exact-match rule looked strict. But the RP had an open redirect at https://app.target.com/oauth/cb?next=URL. So the AS accepted redirect_uri=https://app.target.com/oauth/cb?next=https://attacker.com (matched the prefix), and the RP’s cb handler redirected to attacker.com with the code as a query param. The code never reached the legitimate callback handler. Lesson: redirect_uri validation must check the entire URL, including any open-redirect parameters that exist downstream. The fix was a single exact-string allowlist per client_id — no prefix matching at all.

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

Terminal window
# 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.

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?

→ W11 (Authentication Bypass) · W13 (JWT Attacks) · W15 (API Security (REST))