OAuth 2.0 is the protocol most modern applications depend on for authentication and authorisation, and it is also the protocol most teams implement once, never audit, and quietly leave broken for years. The specifications are dense, the libraries are inconsistent, and the documentation favours the happy path. The result is a long tail of misconfigurations that show up in nearly every offensive engagement against a SaaS or API-first product. After working through OAuth audits across many API penetration testing engagements, the same dozen misconfigurations surface so reliably that they read more like a checklist than a list of findings. This is the field-tested breakdown — twelve patterns that account for the overwhelming majority of OAuth-related findings, the attack against each, and the specific server-side fix that closes it.
Why OAuth audits keep missing the same patterns
The pattern recognition gap between OAuth specification authors and OAuth implementers is the root cause of nearly every finding in this article.
The library convenience trap
OAuth libraries are designed to make implementation fast. The convenience defaults — allowing wildcards in redirect URI matching, accepting implicit flow, deferring PKCE enforcement, treating token validation as the application’s responsibility — make demonstrations work in a tutorial but leave production systems exposed. Teams that integrate quickly using copy-paste from documentation rarely revisit the configuration once tokens start flowing.
The “we use a managed IdP” assumption
Teams using Auth0, Okta, Cognito, Azure AD B2C, or Firebase Authentication often assume the identity provider handles security correctly. The IdPs do — but they expose configuration knobs that the integrating team owns. Redirect URI lists, allowed grant types, refresh token rotation, scope-to-audience mapping, and JWT validation logic are configured by the customer, not the vendor. A managed IdP with a misconfigured tenant produces the same outcome as a vulnerable self-hosted server.
The audit blind spot
Standard application security audits look at the application code. OAuth misconfigurations live mostly in the identity provider configuration, the client registration metadata, and the token validation logic — surfaces that a typical SAST tool or DAST scan never inspects. The result is OAuth issues persisting through years of audits that “passed” because they tested the wrong layer.
The 12 misconfigurations at a glance
| # | Misconfiguration | Severity | Where it lives |
|---|---|---|---|
| 1 | Wildcard or loose redirect_uri matching | Critical | IdP / authorization server config |
| 2 | Implicit flow (response_type=token) still enabled | High | IdP / client config |
| 3 | Missing or weak state parameter | High | Client implementation |
| 4 | PKCE not required for public clients | Critical | IdP / client config |
| 5 | Authorization code accepted more than once | Critical | Authorization server |
| 6 | Refresh tokens issued without rotation | High | Authorization server |
| 7 | Scope expansion via authorization request manipulation | High | Authorization server |
| 8 | JWT signature validation flaws (alg confusion, alg: none) | Critical | Resource server / library config |
| 9 | JWT kid injection / unrestricted JWKS fetching | Critical | Resource server |
| 10 | Audience (aud) claim not validated | High | Resource server |
| 11 | Token endpoint without client authentication on confidential clients | Critical | Authorization server |
| 12 | Dynamic client registration without admin gating | High | IdP / authorization server |
Each row in this table represents a finding category that appears in well over half of OAuth-integrated applications we audit. The implication is sobering: the typical SaaS in 2026 has multiple of these live in production at any given time.
Misconfig 1 — Wildcard or loose redirect_uri matching
The attack
The OAuth authorization code flow ends with the authorization server redirecting the user’s browser to a URL containing the authorization code. If the authorization server accepts a redirect URI that the attacker controls — through a wildcard, prefix match, or substring match instead of exact match — the attacker captures the code and exchanges it for tokens.
Specific bypasses seen in production:
redirect_uri=https://app.example.com/callbackregistered. Server acceptshttps://app.example.com/callback@evil.combecause it parses URLs loosely.- Registered
https://app.example.com/*. Server acceptshttps://app.example.com.evil.com/callback. - Registered
https://example.com/callback. Server accepts open redirect inside the same domain, e.g.https://example.com/redirect?url=https://evil.com/callback. - Multiple URIs registered including a localhost dev URL. Attacker spins up a service on the dev port from a malicious extension.
The fix
Enforce exact-match against the registered list. No wildcards. No prefix matching. No substring matching. Reject any redirect URI not byte-identical to one on the allow-list, including query string and fragment normalisation.
// Correct: exact match policy
{
"redirect_uris": [
"https://app.example.com/oauth/callback",
"https://app.example.com/oauth/silent"
],
"redirect_uri_validation": "exact_match"
}
For SPAs that need multiple environments, register each environment URL separately. For mobile apps, use custom URI schemes registered to the specific app via app links / universal links.
Misconfig 2 — Implicit flow still in production
The attack
The implicit flow (response_type=token or response_type=id_token token) returns the access token directly in the URL fragment. Browser history, server logs, referrer headers, and browser extensions all see the token. The flow has been deprecated by OAuth 2.1 specifically because of these leakage paths.
Once an access token is captured from a URL fragment, the attacker has full access to whatever scopes the token grants until expiration — and tokens issued by the implicit flow typically cannot be revoked.
The fix
Disable the implicit flow at the authorization server level. Migrate SPAs to the authorization code flow with PKCE. The migration is now straightforward across all major OAuth libraries — oauth4webapi, @auth0/auth0-spa-js, oidc-client-ts, MSAL.js, and others support PKCE out of the box.
For confidential clients, the choice is straightforward: authorization code flow with client authentication. For public clients (SPAs, mobile, native), the choice is authorization code flow with PKCE.
Misconfig 3 — Missing or weak state parameter
The attack
The state parameter binds the authorization request to a specific user’s session, preventing CSRF attacks on the OAuth callback. Without it, an attacker can initiate an authorization flow with their own account, capture the callback URL, and trick a victim into visiting the URL while logged into the same site — the victim’s session ends up bound to the attacker’s identity, or vice versa depending on the integration pattern.
Weak state implementations include:
- Static or predictable state values
- State value not validated server-side on callback
- State value validated only for presence, not content match against the originating session
- State value generated client-side without server binding
The fix
Generate a cryptographically random state value (128 bits minimum) at the start of every authorization request, bind it to the user’s session server-side, and reject any callback where the state does not exactly match the value bound to the current session.
# Server-side state generation and validation
import secrets
def initiate_oauth_flow(session):
state = secrets.token_urlsafe(32)
session["oauth_state"] = state
return f"https://idp.example.com/authorize?state={state}&..."
def handle_callback(session, request):
expected = session.pop("oauth_state", None)
received = request.args.get("state")
if not expected or expected != received:
raise SecurityError("OAuth state mismatch — possible CSRF")
The state must be invalidated server-side as soon as it is consumed. Replays of a valid state value must fail.
Misconfig 4 — PKCE not required for public clients
The attack
PKCE (Proof Key for Code Exchange) protects the authorization code from interception during the redirect. Without PKCE, an attacker who captures the authorization code through a network MITM, a compromised browser extension, or a malicious app on the same device can exchange it for tokens.
The attack is well-documented for mobile apps where a malicious app can register the same custom URI scheme as the legitimate app. The malicious app receives the authorization code and exchanges it for tokens.
The fix
Require PKCE for all public clients. The authorization server should reject any authorization request from a public client that does not include a code_challenge parameter, and the token endpoint should reject any code exchange that does not include the matching code_verifier.
GET /authorize?
response_type=code
&client_id=public-spa-client
&redirect_uri=https://app.example.com/cb
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&state=<random>
code_challenge_method=plain should also be rejected — only S256 provides meaningful protection. Confidential clients that authenticate at the token endpoint do not technically require PKCE but enabling it defence-in-depth is increasingly considered the correct default.
Misconfig 5 — Authorization code accepted more than once
The attack
Authorization codes must be single-use. If the authorization server accepts the same code more than once, an attacker who replays a captured code can obtain a fresh set of tokens — defeating the security model that assumes code interception is rare and recovery is possible by token revocation.
In production audits, this typically appears as:
- Authorization server caches code redemption but the cache TTL is longer than the code lifetime
- Multiple instances of the authorization server without shared state for code redemption
- Code marked as consumed after token issuance, allowing race conditions
The fix
Mark codes as consumed atomically at the start of redemption. If a code is presented twice, fail the second presentation AND revoke any tokens issued from the first redemption — the second presentation indicates the code was leaked.
def redeem_code(code, client_id, code_verifier):
with db.transaction():
record = db.fetch_for_update("SELECT * FROM auth_codes WHERE code = %s", code)
if not record:
raise OAuthError("invalid_grant")
if record.consumed:
revoke_tokens_for_code(code) # second use = leak signal
raise OAuthError("invalid_grant")
if record.expires_at < now():
raise OAuthError("invalid_grant")
db.execute("UPDATE auth_codes SET consumed = TRUE WHERE code = %s", code)
return issue_tokens(record, code_verifier)
Misconfig 6 — Refresh tokens without rotation
The attack
Refresh tokens often have a much longer lifetime than access tokens — sometimes days, weeks, or indefinite. Without rotation, a refresh token leak is a long-lived compromise that the user cannot detect or invalidate without explicit revocation.
The “reuse detection” pattern (OAuth 2.1 recommendation) requires that when a refresh token is presented, the server issues a new refresh token and invalidates the old one. If the old one is ever presented again, both the old and the new refresh token (and all derived access tokens) are invalidated and the user is logged out.
The fix
Issue a new refresh token on every refresh token redemption, invalidate the previous one, and detect reuse by maintaining the family of refresh tokens derived from the original grant. On reuse detection, revoke the entire family.
def refresh(refresh_token):
record = db.fetch("SELECT * FROM refresh_tokens WHERE token = %s", refresh_token)
if not record:
raise OAuthError("invalid_grant")
if record.revoked:
# Reuse of a previously-rotated token: revoke the entire family
db.execute("UPDATE refresh_tokens SET revoked = TRUE WHERE family_id = %s",
record.family_id)
log_security_event("refresh_token_reuse", record.user_id, record.family_id)
raise OAuthError("invalid_grant")
new_refresh = secrets.token_urlsafe(32)
db.execute("UPDATE refresh_tokens SET revoked = TRUE WHERE id = %s", record.id)
db.execute("""INSERT INTO refresh_tokens (token, family_id, user_id, expires_at)
VALUES (%s, %s, %s, %s)""",
new_refresh, record.family_id, record.user_id, expiry())
return issue_access_token(record.user_id), new_refresh
Misconfig 7 — Scope expansion via authorization request manipulation
The attack
The authorization request includes the scopes the client is asking for. If the authorization server does not validate that the requested scopes are within the scopes registered for that client, an attacker controlling the authorization URL can request scopes the client was never authorised to ask for.
A common production pattern: a client registered for openid profile email accidentally accepts openid profile email admin from a manipulated authorization request, because the server only checks scope syntax, not scope policy.
A more subtle variant: an attacker tricks a legitimate user into authorising scopes the user does not realise they are granting. The consent screen displays the attacker-supplied scope list rather than the client’s registered scope list.
The fix
Enforce that the requested scopes are a subset of the client’s registered scopes. Reject any authorization request that includes scopes outside the registered set. Additionally, validate that the user has been granted access to those scopes via the application’s own authorisation model — OAuth scopes are not a substitute for application-level role checks.
Misconfig 8 — JWT signature validation flaws
The attack
JWT (JSON Web Token) is widely used as the access token or ID token format. The signature validation logic is where most JWT-related vulnerabilities live.
Specific attacks:
alg: none: an attacker forges a token withalg: nonein the header and no signature. A naive library trusts the algorithm field and accepts the unsigned token.- HS256 / RS256 confusion: an attacker takes a token signed RS256 (asymmetric) and re-signs it HS256 (symmetric) using the server’s public key as the HMAC secret. A naive library that doesn’t enforce algorithm whitelist accepts the forgery.
- Signature stripping: an attacker removes the signature entirely and submits the token. Libraries that fall back to “verify if signature present, accept if absent” fail open.
The fix
Pin the expected signature algorithm to the verification function call. Reject any token where the header algorithm does not match the expected algorithm. Reject alg: none unconditionally.
// jsonwebtoken (Node.js) — correct usage
const jwt = require("jsonwebtoken");
const decoded = jwt.verify(token, publicKey, {
algorithms: ["RS256"],
audience: "https://api.example.com",
issuer: "https://idp.example.com",
});
The algorithms option must be passed explicitly. Without it, some library versions infer the algorithm from the token header — the exact bug that enables the confusion attack.
Misconfig 9 — JWT kid injection and JWKS abuse
The attack
The JWT header includes a kid (key ID) value that tells the verifier which key to use for signature verification. If the verifier uses kid to fetch a key from a remote JWKS endpoint without validation, an attacker can:
- Set
kidto a path-traversal string that loads a file from the filesystem (then sign with that file’s contents) - Set
kidto a URL pointing at attacker-controlled JWKS hosting (some libraries fetch keys from arbitrary URLs) - Set
kidto an SQL injection payload if the kid lookup hits a database
The fix
Treat kid as an opaque lookup key into a trusted, locally-cached JWKS. Never use kid directly as a URL, filename, or database query. Cache the JWKS from the trusted issuer with a known refresh policy, and use the kid as an index into that cached set.
# Correct kid handling
import requests
from functools import lru_cache
ISSUER_JWKS_URL = "https://idp.example.com/.well-known/jwks.json"
@lru_cache(maxsize=1)
def get_jwks():
response = requests.get(ISSUER_JWKS_URL, timeout=5)
return {k["kid"]: k for k in response.json()["keys"]}
def verify_token(token):
header = jwt.get_unverified_header(token)
kid = header.get("kid")
if not kid or not isinstance(kid, str):
raise SecurityError("Missing or invalid kid")
keys = get_jwks()
key = keys.get(kid)
if not key:
raise SecurityError("Unknown kid")
return jwt.decode(token, key, algorithms=["RS256"], audience=EXPECTED_AUD)
The JWKS URL is hardcoded to the trusted issuer. The kid is used only as a dictionary key into the pre-fetched set.
Misconfig 10 — Audience claim not validated
The attack
The aud (audience) claim in a JWT identifies which resource server the token is intended for. If a resource server accepts tokens regardless of audience, an attacker who obtains a token issued for a different audience (perhaps a sibling service in the same identity provider) can present it and the resource server will accept it.
This is particularly dangerous in environments with multiple resource servers behind a single IdP — a token for the read-only analytics API gets accepted by the write-capable production API.
The fix
Every resource server validates that the aud claim matches its expected audience identifier. Tokens with mismatched audience are rejected with the same error as invalid tokens.
EXPECTED_AUD = "https://api.example.com"
decoded = jwt.decode(
token,
public_key,
algorithms=["RS256"],
audience=EXPECTED_AUD, # rejects if aud != expected
issuer="https://idp.example.com",
)
If aud is an array, validate that the expected audience is in the array. Do not accept tokens where aud is missing.
Misconfig 11 — Token endpoint without client authentication
The attack
Confidential clients (server-side web apps, backend services) must authenticate to the token endpoint using either client_secret_basic, client_secret_post, or one of the more modern methods (private_key_jwt, tls_client_auth). If the authorization server does not enforce this for confidential clients, an attacker who captures an authorization code can redeem it without knowing the client secret.
The attack surface is wider than expected — many enterprise tenants register a client as “confidential” but never configure a secret, expecting the IdP to enforce authentication. Some IdPs default to “no client auth required” if no secret is set.
The fix
For every confidential client, configure a strong secret (or a private key for private_key_jwt) and configure the IdP to require it. Audit existing client registrations to verify each “confidential” client actually has authentication configured.
Public clients (browsers, mobile apps) cannot keep a secret, so they use PKCE for proof instead. The distinction matters: PKCE is for public clients, client authentication is for confidential clients. Some implementations conflate the two.
Misconfig 12 — Dynamic client registration without admin gating
The attack
OAuth’s Dynamic Client Registration (DCR) allows new clients to be registered programmatically. Some IdPs enable DCR by default and expose the endpoint publicly. An attacker can register a malicious client with attacker-controlled redirect URIs, then trick users into authorising scopes for that client.
DCR is genuinely useful — it powers integration ecosystems where third parties register their own clients. The misconfiguration is exposing DCR without gating it behind an admin approval workflow, an API key, or a similar control.
The fix
If DCR is needed, gate it behind authenticated admin approval. The DCR endpoint should require an authentication token issued to an authorised actor. Registered clients should not become active until reviewed.
If DCR is not needed, disable it. Many tenants enable it by default and never use it — leaving an unnecessary attack surface exposed.
# OIDC discovery snippet showing DCR endpoint
# If you don't need this, remove it from your discovery document
{
"registration_endpoint": "https://idp.example.com/register",
"registration_endpoint_auth_methods_supported": ["bearer"]
}
How to systematically audit OAuth in production
A repeatable audit methodology that covers the twelve patterns above:
- Inventory clients. Pull the full client list from the IdP. For each: grant types allowed, redirect URIs, registered scopes, client authentication method, PKCE policy.
- Test redirect URI handling. Submit authorization requests with manipulated redirect URIs — wildcard tests, prefix tests, fragment manipulation, double-encoded values. Expect all to be rejected.
- Test state parameter handling. Submit callbacks with missing state, mismatched state, and replayed state. Expect failures.
- Test PKCE enforcement. Initiate authorization without PKCE. Expect rejection for public clients.
- Test code reuse. Redeem a code, then immediately redeem the same code again. Expect failure and check whether the first set of tokens was also revoked.
- Test refresh token rotation. Use a refresh token. Use it again. Expect family revocation.
- Test scope handling. Request scopes the client was never registered for. Expect rejection at the authorization server.
- Inspect JWT validation. Use a tool like
jwt_toolto testalg: none, algorithm confusion, kid injection, and signature stripping against the resource server. - Inspect audience validation. Obtain a token for one resource server, present it to a sibling resource server. Expect rejection.
- Test client authentication. Attempt code-to-token exchange without client authentication for a confidential client. Expect rejection.
- Audit DCR exposure. Check the OIDC discovery document for
registration_endpoint. If exposed, attempt unauthenticated client registration. Expect failure.
This methodology produces an actionable findings list against any OAuth-integrated application in approximately one engineering day, including verification. For deeper testing context, see our web application pentest methodology and API security testing for SaaS guides.
The detection-and-response layer
OAuth misconfigurations are the cause; OAuth abuse is the effect. The detection layer that catches abuse in production:
| Signal | What it indicates |
|---|---|
| Same authorization code presented multiple times | Code leak or replay attack |
| Refresh token reuse after rotation | Refresh token leak |
| Token issued for unusual user-agent / IP combination | Possible token theft |
| Tokens issued at unusual rate for a client | Possible client compromise |
| State parameter mismatches on callback | Possible CSRF probe |
alg: none JWTs presented to resource server | Active forgery attempt |
| New client registered via DCR | Possible unauthorised registration |
Most of these signals can be logged from the authorization server’s audit log and routed into a SIEM. Integration with managed SOC monitoring closes the detection loop.
The honest implementation checklist
Boiled down from the twelve misconfigurations and the audit methodology, the minimum baseline for an OAuth implementation that will not appear in next year’s pentest report:
- Exact-match redirect URI validation, no wildcards
- Implicit flow disabled at the authorization server
- State parameter generated server-side, bound to session, single-use
- PKCE required for all public clients,
S256only - Authorization codes single-use, reuse triggers family revocation
- Refresh tokens rotated on every use, reuse triggers family revocation
- Requested scopes validated against the client’s registered scopes
- JWT verification calls explicitly pin algorithm and audience
- JWKS fetched from a trusted issuer URL, kid used only as cache index
- Audience claim validated on every resource server
- Client authentication required for confidential clients, enforced at the token endpoint
- Dynamic client registration disabled or gated behind admin approval
- Authorization server audit log shipped to a SIEM with detection rules for the abuse patterns above
Organisations that ship this checklist do not appear in the dataset that this article was derived from. Organisations that ship part of it appear in the dataset every quarter until they ship the rest. The OAuth specification is hard; the audit methodology is not.
The argument was never whether OAuth is secure. The protocol is. Implementations are routinely not. The work is closing the gap between what the specification expects and what the production system actually does — and the twelve patterns above are where to start.