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:

PieceWhat it isWhat it is forCommon confusion
JWT (RFC 7519)A token format: a set of data (claims) that is signed or encryptedCarrying verifiable information between systemsBelieving it is encrypted: a signed JWT can be read in full without any key
OAuth 2.0 (RFC 6749)A delegated authorization frameworkLetting an application access an API on a user's behalf without knowing their passwordUsing it as a login system: an access token does not say who the user is
OpenID ConnectAn identity layer on top of OAuth 2.0Authenticating the user: the ID token says who they are and how they authenticatedSending 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:

AttackHow it worksWhat prevents it
“none” algorithmThe header is changed to "alg": "none" and the signature removed; if the library accepts unsigned tokens, the attacker forges whatever claims they wantAlways reject “none”; the algorithms standard (RFC 7518) already says such tokens must not be accepted by default
Algorithm confusionThe API expects RS256; the attacker signs with HS256 using the RSA public key as the HMAC secret, and a misconfigured library accepts itPin the expected algorithm for each key on the server, without reading it from the token
Weak HMAC secretWith one valid token, the secret is brute-forced offline; if it is a word or a short password, it fallsLong random keys; RFC 8725 forbids using human-memorisable passwords as an HMAC key
Signature never verifiedThe code decodes the token to read the claims but never calls the function that verifies the signatureUse only the library's verify function; test with tampered tokens
kid manipulationThe key identifier is used unvalidated to look up the key in a file or a database, enabling path traversal or injectionTreat kid as a value from a closed list
Attacker-controlled jku or x5uThe header points to an attacker URL serving their own public keysIgnore those headers or accept only URLs from a fixed list; RFC 8725 warns about injection and SSRF
Claims not validatedThe signature is checked but not expiry, issuer or audience: an expired token, or one issued for another service, still worksValidate 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:

CheckWhat it means in practice
AlgorithmA closed list of allowed algorithms, pinned on the server; each key is used with a single algorithm
SignatureAlways verified with the right key, obtained from the trusted issuer's configuration, not from the token header
Expiry and validityexp and nbf checked, with a small clock skew allowance
Issueriss matches the expected authorization server exactly
Audienceaud includes this API; a token issued for another service is rejected
TypeThe token's explicit type (typ) prevents accepting, for example, an ID token where an access token is expected
PermissionsThe token's scopes and roles are enforced on every endpoint, and object-level authorization is still checked separately
RevocationFor 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:

PatternWhere the token livesMain riskWhen it fits
Backend for Frontend (BFF)In your own backend; the browser only holds an HttpOnly session cookieThe cookie must be protected (SameSite, CSRF protection)The RFC expressly recommends it for business applications, sensitive applications and those handling personal data
Token-mediating backendThe backend obtains the tokens and keeps the refresh token; the browser receives the access token and calls the APIs with itAn XSS can steal or use the access token, though not the refresh tokenWhen the frontend needs to call third-party APIs directly
Browser-only OAuth clientIn the application's JavaScript, in memory or in browser storageAn XSS can steal or use themThe 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:

MistakeWhat it allowsWhat RFC 9700 says
Loose redirect_uri validation (prefixes, wildcards, subdomains)The authorization code or token being sent to an attacker URLAuthorization servers must compare redirect URIs by exact string matching (except the port for native apps on localhost)
No PKCE or stateInjection of stolen codes and CSRF in the flowPKCE mandatory for public clients and recommended for confidential ones; the server must support it
Implicit flowThe token travels in the URL, exposed in history, logs and Referer headers, and cannot be bound to the clientClients should not use it; the alternative is the authorization code flow with PKCE
Resource owner password credentialsThe application receives the user's password, and the flow supports neither MFA nor federationMust not be used
Tokens without audience restrictionA token issued for one API works on anotherAccess tokens should be restricted to a specific resource server
Reusable stolen tokensWhoever copies a token can use it from anywhereSender-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:

ScenarioReasonable optionWatch out for
Your own web application with its backendServer session with an HttpOnly cookie, or a BFF if there is an identity providerBuilding a hand-rolled JWT system for something a session solves
Mobile app or SPA with usersOAuth 2.0 authorization code with PKCE against an identity providerStoring client secrets in the app, long-lived tokens
Third-party applications acting on behalf of your usersOAuth 2.0 with consent and narrow scopesOverly broad scopes, no way for users to revoke
Server-to-server integration with a partnerOAuth client credentials or API keys with rotation, restricted by IP or mTLSKeys shared by email that never expire
Internal services calling each otherService 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

Sources

Audit my API's authentication and tokens