JWT and OAuth 2.0 security in APIs: the mistakes attackers exploit and what to validate
By Kike Gandia · Co-Founder & CEO, OSCP
Almost every modern API authenticates with tokens: signed JWTs, access tokens issued by an OAuth 2.0 server or OpenID Connect ID tokens. Implemented well, they are robust; validated badly, they let attackers forge tokens, reuse them where they should not work or steal them in transit. This guide separates what each piece is, reviews the JWT attacks that still turn up in penetration tests, what an API has to check on every token, where to store tokens in a web application and the OAuth flow mistakes that the IETF's current best practice (RFC 9700) forbids or discourages.
JWT, OAuth 2.0 and OpenID Connect: what each one is
They are mentioned together and often confused, but they are different pieces:
| Piece | What it is | What it is for | Common confusion |
|---|---|---|---|
| JWT (RFC 7519) | A token format: a set of data (claims) that is signed or encrypted | Carrying verifiable information between systems | Believing it is encrypted: a signed JWT can be read in full without any key |
| OAuth 2.0 (RFC 6749) | A delegated authorization framework | Letting an application access an API on a user's behalf without knowing their password | Using it as a login system: an access token does not say who the user is |
| OpenID Connect | An identity layer on top of OAuth 2.0 | Authenticating the user: the ID token says who they are and how they authenticated | Sending the ID token to the API as if it were an access token |
An OAuth server can issue access tokens as JWTs or as opaque tokens (a random string the API validates by asking the server). And JWT can be used without OAuth, as many APIs do when they issue their own tokens after login. So the problems fall into two groups: those of the token itself, which depend on how it is signed and validated, and those of the flow, which depend on how it is obtained.
Anatomy of a JWT: what travels in the clear
A signed JWT has three parts separated by dots: header, payload and signature. The first two are base64url-encoded JSON, which is not encryption: anyone who intercepts or copies the token can read them.
- Header: the signing algorithm (alg), the token type (typ) and sometimes the key identifier (kid) or a URL to fetch the key from (jku, x5u).
- Payload: the claims. Those registered in the standard are issuer (iss), subject (sub), audience (aud), expiry (exp), not-before (nbf), issued-at (iat) and a unique identifier (jti). Each application adds its own: roles, scopes, organisation ID.
- Signature: computed over the two previous parts with a shared secret key (HMAC, such as HS256) or with a private key whose public counterpart the verifier knows (RSA or elliptic curves, such as RS256 or ES256).
Two practical consequences. The signature only protects integrity if someone verifies it, with the right algorithm and key. And nothing that must remain confidential —unnecessary personal data, internal information, secrets— should go in a signed JWT's payload. An encrypted variant (JWE) exists, but it is rare in APIs.
The JWT attacks that still work
Most of them do not break the cryptography; they break the way the application uses it:
| Attack | How it works | What prevents it |
|---|---|---|
| “none” algorithm | The header is changed to "alg": "none" and the signature removed; if the library accepts unsigned tokens, the attacker forges whatever claims they want | Always reject “none”; the algorithms standard (RFC 7518) already says such tokens must not be accepted by default |
| Algorithm confusion | The API expects RS256; the attacker signs with HS256 using the RSA public key as the HMAC secret, and a misconfigured library accepts it | Pin the expected algorithm for each key on the server, without reading it from the token |
| Weak HMAC secret | With one valid token, the secret is brute-forced offline; if it is a word or a short password, it falls | Long random keys; RFC 8725 forbids using human-memorisable passwords as an HMAC key |
| Signature never verified | The code decodes the token to read the claims but never calls the function that verifies the signature | Use only the library's verify function; test with tampered tokens |
| kid manipulation | The key identifier is used unvalidated to look up the key in a file or a database, enabling path traversal or injection | Treat kid as a value from a closed list |
| Attacker-controlled jku or x5u | The header points to an attacker URL serving their own public keys | Ignore those headers or accept only URLs from a fixed list; RFC 8725 warns about injection and SSRF |
| Claims not validated | The signature is checked but not expiry, issuer or audience: an expired token, or one issued for another service, still works | Validate exp, nbf, iss and aud on every request |
These are not 2015 problems. Algorithm confusion led, for example, to CVE-2015-9235 in the Node.js jsonwebtoken library (versions before 4.2.2), and in 2022 the vulnerability known as “Psychic Signatures” (CVE-2022-21449) made several Java versions accept ECDSA signatures made entirely of zeros, which according to its discoverer allowed forging JWTs signed with ES256. They keep turning up in penetration tests, mainly in hand-written validation and in old or misconfigured libraries.
What the API has to check on every token
RFC 8725, the IETF's best practice guide for JWT, and RFC 9700, the OAuth 2.0 security one, sum up what an API must verify before trusting a token:
| Check | What it means in practice |
|---|---|
| Algorithm | A closed list of allowed algorithms, pinned on the server; each key is used with a single algorithm |
| Signature | Always verified with the right key, obtained from the trusted issuer's configuration, not from the token header |
| Expiry and validity | exp and nbf checked, with a small clock skew allowance |
| Issuer | iss matches the expected authorization server exactly |
| Audience | aud includes this API; a token issued for another service is rejected |
| Type | The token's explicit type (typ) prevents accepting, for example, an ID token where an access token is expected |
| Permissions | The token's scopes and roles are enforced on every endpoint, and object-level authorization is still checked separately |
| Revocation | For critical operations, check that the token or session has not been revoked |
The permissions row deserves a clarification: a valid token says who is calling and which scopes they have, not whether they may access order 58213. That per-object check is a different control, and its absence is the most common API flaw (BOLA).
In microservice architectures, the temptation is to validate the token only at the gateway. That works as long as nobody reaches the internal services another way; where possible, each service should at least validate the signature and audience of what it receives.
Expiry, revocation and refresh tokens
A JWT's appeal is that the API validates it without asking anyone. The flip side is that, once issued, it stays valid until it expires, even if the user logs out, changes their password or loses access. The usual way to balance this:
- Short-lived access tokens: minutes, not days. If one is stolen, the window for using it is small.
- Refresh tokens to renew the session, stored more carefully than the access token and revocable on the server. RFC 9700 requires refresh tokens for public clients —mobile or browser applications, which cannot keep a secret— to be sender-constrained or rotated on every use, so that reuse of an already-used one reveals the theft.
- Revocation for critical events: password change, an employee leaving or detected compromise. A list of revoked token identifiers (jti) or sessions can be kept and checked by the APIs, at least on sensitive operations.
- Sender-constrained tokens: with mTLS (RFC 8705) or DPoP (RFC 9449), the token can only be used by whoever holds a specific key, so stealing it is not enough. RFC 9700 recommends using these mechanisms.
A frequent pentest finding is a logout that only deletes the token from the browser: the token keeps working against the API until it expires, and if it expires in 30 days, logout is decorative.
Where to store tokens in a web application
The “localStorage or cookie?” question now has a fuller official answer. In August 2026 the IETF published RFC 10017, “OAuth 2.0 for Browser-Based Applications”, which ranks the architecture patterns from most to least secure:
| Pattern | Where the token lives | Main risk | When it fits |
|---|---|---|---|
| Backend for Frontend (BFF) | In your own backend; the browser only holds an HttpOnly session cookie | The cookie must be protected (SameSite, CSRF protection) | The RFC expressly recommends it for business applications, sensitive applications and those handling personal data |
| Token-mediating backend | The backend obtains the tokens and keeps the refresh token; the browser receives the access token and calls the APIs with it | An XSS can steal or use the access token, though not the refresh token | When the frontend needs to call third-party APIs directly |
| Browser-only OAuth client | In the application's JavaScript, in memory or in browser storage | An XSS can steal or use them | The RFC does not recommend it for business, sensitive or personal-data applications |
The reason is simple: any token the page's JavaScript can reach is within reach of an attacker who manages to run code in it through an XSS or a compromised dependency. Keeping it in memory instead of localStorage narrows the window but does not close it. With a BFF, the browser never sees the access token.
In mobile apps the equivalent is the system's secure storage (Keychain on iOS, Keystore on Android) and the authorization flow with PKCE through the system browser, never through an embedded web view asking for the password.
The OAuth 2.0 mistakes current best practice forbids
RFC 9700, published in January 2025, updates OAuth 2.0 security recommendations with what a decade of attacks has taught. These are the most common mistakes, with what the document says:
| Mistake | What it allows | What RFC 9700 says |
|---|---|---|
| Loose redirect_uri validation (prefixes, wildcards, subdomains) | The authorization code or token being sent to an attacker URL | Authorization servers must compare redirect URIs by exact string matching (except the port for native apps on localhost) |
| No PKCE or state | Injection of stolen codes and CSRF in the flow | PKCE mandatory for public clients and recommended for confidential ones; the server must support it |
| Implicit flow | The token travels in the URL, exposed in history, logs and Referer headers, and cannot be bound to the client | Clients should not use it; the alternative is the authorization code flow with PKCE |
| Resource owner password credentials | The application receives the user's password, and the flow supports neither MFA nor federation | Must not be used |
| Tokens without audience restriction | A token issued for one API works on another | Access tokens should be restricted to a specific resource server |
| Reusable stolen tokens | Whoever copies a token can use it from anywhere | Sender-constrained tokens (mTLS or DPoP) should be used |
On top of these come implementation mistakes that do not depend on the standard: client secrets embedded in mobile apps or JavaScript, which stop being secret as soon as someone downloads the application; scopes broader than needed requested “just in case”; open redirects in the client itself which, combined with loose validation, allow codes to be stolen; and tokens ending up in logs because they are passed as URL parameters.
OAuth 2.1, which consolidates these practices into a single document —PKCE for everyone, no implicit or password flow— is still an IETF draft as of October 2026, but what it contains is already in RFC 9700.
API keys, your own JWTs or OAuth: what to use when
Not every API needs OAuth, and forcing it where it does not fit also causes mistakes:
| Scenario | Reasonable option | Watch out for |
|---|---|---|
| Your own web application with its backend | Server session with an HttpOnly cookie, or a BFF if there is an identity provider | Building a hand-rolled JWT system for something a session solves |
| Mobile app or SPA with users | OAuth 2.0 authorization code with PKCE against an identity provider | Storing client secrets in the app, long-lived tokens |
| Third-party applications acting on behalf of your users | OAuth 2.0 with consent and narrow scopes | Overly broad scopes, no way for users to revoke |
| Server-to-server integration with a partner | OAuth client credentials or API keys with rotation, restricted by IP or mTLS | Keys shared by email that never expire |
| Internal services calling each other | Service identity (mTLS or short-lived tokens with an audience) | Trusting the internal network without authentication |
API keys are not insecure by definition, but they identify an application, not a user, and often live for years without rotation. If you use them, they must be revocable one by one, carry least privilege and never appear in code distributed to clients.
How API authentication is audited
In an API penetration test, token-based authentication is tested systematically, because a flaw here affects every endpoint at once:
- Token tampering: changing claims (role, organisation, user) and checking whether the API notices the signature no longer matches; trying “none”, algorithm confusion and unsigned tokens.
- HMAC secret: if the algorithm is HS256, trying to crack the secret offline from a valid token.
- Claim validation: expired tokens, tokens with another service's audience, from another issuer or from a test environment accepted in production.
- Key headers: tampered kid, jku and x5u.
- Lifecycle: whether logout invalidates the token at the API, whether a rotated refresh token can be reused, whether a password change revokes sessions.
- OAuth flows: redirect_uri variations, missing PKCE and state, implicit or password flows enabled, scopes that can be widened in the request, tokens in URLs and logs.
- Client-side storage: where the web or mobile application keeps tokens and whether an XSS or an analysis of the app exposes them.
Wherever possible, the configuration of the library that validates tokens is also reviewed: many of the flaws above are invisible from outside until the specific case is tested, and one line of configuration explains them all.
FAQ
Is a JWT encrypted?
Usually not. Most JWTs are signed, not encrypted: the header and payload are base64url JSON and anyone can read them. The signature prevents tampering going unnoticed, as long as it is verified. If the content must be confidential, it should not be in the token, or the encrypted variant (JWE) should be used.
Is it better to use JWTs or cookie sessions?
It depends on the architecture. For a web application with its own backend, a server session with an HttpOnly cookie is simpler and can be revoked instantly. JWTs fit when several APIs or services need to validate identity without querying a central store, at the cost of managing expiry and revocation properly.
How long should an access token last?
As short as the user experience allows: minutes, not days. Long sessions come from refresh tokens, which can be revoked on the server and, for public clients, rotated on every use. An access token lasting weeks turns any theft into lasting access and makes logout meaningless.
Can I use OAuth 2.0 to log my users in?
Not directly: OAuth 2.0 is an authorization framework and an access token is not designed to say who the user is. Authentication uses OpenID Connect, which adds the ID token with the user's identity and how they authenticated. Using an access token as proof of identity is a classic mistake.
Is the OAuth implicit flow still acceptable?
It is discouraged. RFC 9700, the current OAuth 2.0 security guidance, states that clients should not use it, because it exposes the token in the URL and does not allow binding it to the client. The alternative is the authorization code flow with PKCE, in browser applications too.
What is OAuth 2.1 and do I need to migrate?
It is a revision of OAuth 2.0 that gathers the already published best practices into a single document: PKCE for every client, no implicit or password flow and exact matching of redirect URIs. As of October 2026 it is still an IETF draft. If you already follow RFC 9700, you are practically aligned.
Related service
API penetration testing, including OAuth 2.0 and OpenID Connect flows
Related content
- The OWASP API Security Top 10 explained
- What BOLA (IDOR) is: the authorization a token does not solve
- OAuth consent phishing in Google Workspace
- Common SaaS vulnerabilities, including weak JWTs
- Mobile application penetration testing
- Web application penetration testing