🔌 API Security July 20, 2026 · 14 min read

OAuth 2.0 Misconfigurations Attackers Actually Exploit

12 OAuth 2.0 misconfigurations attackers actually exploit — open redirects, token leaks, scope abuse, PKCE bypass. Patterns and the fixes that close them.

API
🔌 API Security
API

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

#MisconfigurationSeverityWhere it lives
1Wildcard or loose redirect_uri matchingCriticalIdP / authorization server config
2Implicit flow (response_type=token) still enabledHighIdP / client config
3Missing or weak state parameterHighClient implementation
4PKCE not required for public clientsCriticalIdP / client config
5Authorization code accepted more than onceCriticalAuthorization server
6Refresh tokens issued without rotationHighAuthorization server
7Scope expansion via authorization request manipulationHighAuthorization server
8JWT signature validation flaws (alg confusion, alg: none)CriticalResource server / library config
9JWT kid injection / unrestricted JWKS fetchingCriticalResource server
10Audience (aud) claim not validatedHighResource server
11Token endpoint without client authentication on confidential clientsCriticalAuthorization server
12Dynamic client registration without admin gatingHighIdP / 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/callback registered. Server accepts https://app.example.com/callback@evil.com because it parses URLs loosely.
  • Registered https://app.example.com/*. Server accepts https://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 with alg: none in 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 kid to a path-traversal string that loads a file from the filesystem (then sign with that file’s contents)
  • Set kid to a URL pointing at attacker-controlled JWKS hosting (some libraries fetch keys from arbitrary URLs)
  • Set kid to 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:

  1. Inventory clients. Pull the full client list from the IdP. For each: grant types allowed, redirect URIs, registered scopes, client authentication method, PKCE policy.
  2. 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.
  3. Test state parameter handling. Submit callbacks with missing state, mismatched state, and replayed state. Expect failures.
  4. Test PKCE enforcement. Initiate authorization without PKCE. Expect rejection for public clients.
  5. 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.
  6. Test refresh token rotation. Use a refresh token. Use it again. Expect family revocation.
  7. Test scope handling. Request scopes the client was never registered for. Expect rejection at the authorization server.
  8. Inspect JWT validation. Use a tool like jwt_tool to test alg: none, algorithm confusion, kid injection, and signature stripping against the resource server.
  9. Inspect audience validation. Obtain a token for one resource server, present it to a sibling resource server. Expect rejection.
  10. Test client authentication. Attempt code-to-token exchange without client authentication for a confidential client. Expect rejection.
  11. 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:

SignalWhat it indicates
Same authorization code presented multiple timesCode leak or replay attack
Refresh token reuse after rotationRefresh token leak
Token issued for unusual user-agent / IP combinationPossible token theft
Tokens issued at unusual rate for a clientPossible client compromise
State parameter mismatches on callbackPossible CSRF probe
alg: none JWTs presented to resource serverActive forgery attempt
New client registered via DCRPossible 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, S256 only
  • 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.

#OAuth #API Security #Authentication #JWT #AppSec

Related articles

Need expert help with API Security?

Our certified security team is ready to assess your environment and recommend the right solutions.

Book a Free Consultation