APIs are how modern systems actually talk to each other, and authentication is where most API security incidents start. OAuth 2.0 and OpenID Connect (OIDC) are now the lingua franca for delegated access and federated identity, and JSON Web Tokens (JWTs) are the format almost everyone reaches for. They are powerful but easy to misuse: the OWASP API Security Top 10 reads like a list of OAuth and JWT antipatterns. This article walks through how the protocols actually work, the pitfalls that bite even experienced teams, and a hardening checklist that closes most of them.
OAuth 2.0 in one diagram (in words)
OAuth 2.0 is an authorisation framework: it lets a client application get a token that grants it permission to access a resource on behalf of a user (or itself). The cast of characters:
- Resource owner – Usually the end user.
- Client – The application asking for access (a SPA, a mobile app, a backend, a third-party integration).
- Authorisation server – Issues tokens after authenticating the user and getting their consent (Auth0, Okta, Entra ID, Keycloak, Cognito, your own).
- Resource server – The API that holds the data and validates incoming tokens.
The flow depends on the client. The current best-practice flows are:
- Authorization Code with PKCE – The right answer for web apps, SPAs, and mobile apps. The client redirects the user to the auth server, gets back a one-time code, and exchanges it (along with the PKCE verifier) for tokens.
- Client Credentials – Machine-to-machine. The client authenticates itself (secret, mTLS, or signed JWT assertion) and gets a token for its own identity. No user involved.
- Device Code – TVs, CLIs, and other input-limited devices.
- Refresh tokens – Long-lived tokens that let clients get new access tokens without re-prompting the user. Powerful, and worth protecting like a password.
Flows you should not use in new code: the Implicit flow (tokens delivered in URL fragments, vulnerable to leakage), the Resource Owner Password Credentials grant (the app collects the user's password directly — defeats the entire point of OAuth), and any home-grown variant that skips PKCE.
OIDC: authentication on top of OAuth
OAuth 2.0 was deliberately not designed for authentication ("who is the user"), only authorisation ("what can this token do"). OpenID Connect (OIDC) is the standard layer on top that adds authentication. The main addition is the ID Token: a signed JWT containing claims about the authenticated user (sub, email, name, aud, iss, iat, exp, nonce).
Key things to remember:
- ID tokens are for the client, access tokens are for the API – Do not send the ID token to your API; do not validate the access token's user claims in the client. Mixing them is a common source of bugs.
- The
nonceclaim binds the ID token to a specific authentication request – Always set it and verify it; this prevents replay. audis critical – The ID token's audience should be your client; the access token's audience should be the target API. Tokens minted for one audience should never be accepted by another.- Use the
userinfoendpoint or signed introspection – For sensitive attributes, do not blindly trust large user objects embedded in tokens.
OIDC also gives you the discovery document (/.well-known/openid-configuration) and JWKS endpoint, which let clients fetch the auth server's public keys to verify token signatures.
JWT pitfalls that keep recurring
JWTs are everywhere because they are convenient: self-contained, signed, and stateless. They also have a long list of historical and active footguns.
alg: none– A JWT withalg: nonehas no signature. Some libraries used to accept it by default. Always reject anything that isn't your expected algorithm.- Algorithm confusion (HS256 vs RS256) – If the verifier accepts whatever algorithm the token says, an attacker who knows the RSA public key can sign HS256 tokens with it as if it were a shared secret. Pin the algorithm on the server side, not from the token header.
kidinjection – Thekid(key ID) header tells the server which key to use. If the server fetches keys based onkidwithout validation (e.g. as a file path, SQL lookup, or URL), an attacker may direct it to a key they control.- Weak HMAC secrets – HS256 with a 12-character secret is brute-forceable. If you must use HMAC, use a long random secret from a secret manager; for anything multi-service, prefer asymmetric (RS256, ES256, EdDSA).
- No expiry or extremely long expiry – Tokens valid for a year are effectively permanent credentials. Access tokens should be minutes; refresh tokens should be days or short weeks with rotation.
- No audience or issuer validation – Always check
issandaudagainst expected values. A token issued for service A should not authenticate to service B. - Storing JWTs in
localStorage– Trivially stolen by any XSS. Prefer HttpOnly, Secure, SameSite cookies for browser-facing tokens, and treat them as such with CSRF protections. - JWTs as session replacements that can't be revoked – By design they are valid until they expire. If you need real-time revocation (compromised account, password change), keep a short expiry and check a revocation list, or use opaque reference tokens with introspection.
- Sensitive data in JWT claims – Anyone with the token can decode it. Never put passwords, PII you don't want logged, or secrets in claims.
A practical rule: use a maintained library (jsonwebtoken, jose, pyjwt, Microsoft.IdentityModel, etc.), pin the expected algorithm and audience explicitly, and never roll your own verification.
API authorisation: where the real bugs live
Even with perfect OAuth and JWT handling, the OWASP API Top 10 is dominated by authorisation failures inside your own code.
- BOLA / IDOR (Broken Object Level Authorisation) –
GET /invoices/42returns invoice 42 to anyone authenticated, instead of verifying it belongs to the current user. The most common API vulnerability by a wide margin. - BFLA (Broken Function Level Authorisation) –
/admin/...endpoints accessible to non-admin tokens because the check is only enforced in the UI. - Mass assignment –
PATCH /users/melets clients set fields likeisAdminbecause the handler trusts the entire JSON body. - Excessive data exposure – APIs return huge objects and rely on the client to display only some fields. Anyone using the API directly sees everything.
- Lack of rate limiting – Brute force, credential stuffing, scraping, and resource exhaustion all need basic per-token and per-IP limits.
- GraphQL-specific issues – Query depth/complexity, introspection in production, batched queries used to bypass rate limits, field-level authorisation gaps.
The fix is unglamorous: a single, centralised authorisation layer (policy engine, middleware) that checks "can this principal perform this action on this object?" on every request, not scattered if user.id == invoice.userId lines that one developer will forget.
A hardening checklist
A reasonable baseline for any production API:
- Authorisation Code + PKCE for user-facing flows; Client Credentials with secret or mTLS for service-to-service. No Implicit, no ROPC.
- Short-lived access tokens (5–15 minutes); refresh tokens with rotation and reuse detection.
- Pinned algorithm, audience, and issuer in token verification. Reject
alg: none, validatenonce, and fetch JWKS over HTTPS with caching. - HttpOnly + Secure + SameSite cookies for browser sessions; never JWTs in
localStorage. - Centralised authorisation middleware enforcing object-, function-, and field-level access; tested with negative cases.
- Strict input validation, including on PATCH/PUT bodies (allow-list of editable fields); reject unknown fields.
- Per-token and per-IP rate limits; aggressive limits on auth, password reset, and account creation endpoints.
- Always HTTPS, HSTS, modern TLS (see TLS 1.3 deep dive); mTLS or signed JWT assertions for high-value service-to-service calls.
- Comprehensive logging of auth events (issuance, refresh, revocation, failures) and authorisation denials, fed into your SIEM.
- Regular API testing with tools like ZAP, Burp, and OWASP API security-focused scanners; treat the OWASP API Top 10 as a real checklist, not a poster.
Most API breaches do not require novel research. They exploit a missing audience check, a too-broad token scope, a leaked client secret in a repo, or an authorisation function that simply was not written. Getting the boring parts right — short tokens, strict validation, centralised authorisation — is what separates APIs that survive contact with the real world from those that don't.