Most JWT and OAuth hacks are not crypto hacks. Nobody has "cracked" HMAC or RSA. They break the configuration: a server that trusts the token header, a
redirect_urichecked "approximately," a refresh token without rotation. In this article, I've covered the main attacks following a single pattern: what it is → why it works → how to fix → how to test. Tests are short Spring Boot tests that expect a401.I covered the basics (JWT structure, claims, signature) in the article "JWT for Beginners" – I'm not repeating it here.
⚡ TL;DR
- ✅ The server, not the token, decides how to verify the signature. Allowed algorithm and keys are only from configuration.
- ✅ Valid signature ≠ valid token. Without checking
iss,aud, andexp, a foreign token will pass. - ✅ In OAuth, redirects are the most dangerous. Exact
redirect_urimatch and PKCE for all clients close most attacks. - ✅ Refresh tokens are the most valuable target. Rotation and detection of reuse are mandatory.
- ✅ ID tokens are not for APIs. They are "credentials" for the client, not a pass.
Article Content:
- 📌 JWT vs OAuth 2.0 vs OIDC
- 📌 Threat Model
- 📌 Attacks on JWT Itself
- 📌 Attacks on Token Storage and Transmission
- 📌 Attacks on OAuth Flow
- 📌 Refresh Tokens
- 📌 ID Token ≠ Access Token
- 📌 OAuth 2.1 and RFC 9700
- ⚖️ When JWT is Not Needed
- 💼 Production Checklist
- 💼 How I Test My API
- ❓ FAQ
- ✅ Conclusions
🧭 JWT vs OAuth 2.0 vs OIDC: What's What
These three terms are constantly mixed up, although they are from different levels. Without this distinction, half of the attacks below will seem confusing.
| JWT | OAuth 2.0 | OpenID Connect (OIDC) | |
|---|---|---|---|
| What it is | Token format | Access delegation protocol | Authentication layer on top of OAuth 2.0 |
| Answers the question | "How to package and sign data?" | "What can this application do on behalf of the user?" | "Who is this user?" |
| Output | Signed string header.payload.signature | Access token (+ refresh token) | ID token (always JWT) + access token |
| Standard | RFC 7519 | RFC 6749 + RFC 9700 | OpenID Connect Core 1.0 |
| Typical example | Session token in a custom API | An application gaining access to your Google Calendar | "Sign in with Google" button |
💡 Key takeaway: they are independent. JWT can be used without OAuth (custom login), and OAuth without JWT (the access token can be an opaque string that the API verifies by querying the authorization server). JWT is mandatory in only one place – the ID token in OIDC.
🎯 Threat Model: Who Attacks and What They Want
Almost all attacks below are variations of three scenarios. I recommend keeping them in mind: it's easier to understand what exactly each setting protects against.
| Who | What they can do | What they want |
|---|---|---|
| Interception Attacker | Sees traffic, logs, browser history, Referer header |
Copy a valid token or authorization code |
| Client-side Script (XSS) | Executes JavaScript within your domain | Read a token from storage or make requests on behalf of the user |
| Malicious Client / Site | Creates its own tokens, substitutes links and redirects | Forge a token or trick the server into sending a code or token to itself |
💡 The first and second steal real tokens – short lifespans, secure storage, and token binding protect against them. The third tries to forge or redirect – strict server-side verification protects against it.
🧨 Attacks on JWT Itself
All checks in this section are tests from the JwtSecurityTest class, a full version of which I provide in the "How I Test My API" section. The test API is a Spring Boot Resource Server that accepts only RS256 tokens from https://auth.example.com for the audience orders-api.
1. alg: none
What it is: an attacker sets "alg": "none" in the header, removes the signature, and writes anything in the payload.
Why it works: the JWT specification allows "unsigned" tokens, and a library that takes the algorithm from the header might accept them.
How to fix: an explicit list of allowed algorithms on the server. none is never in it.
Test:
@Test
void algNoneIsRejected() throws Exception {
expect401(new PlainJWT(validClaims().build()).serialize());
}
2. Algorithm confusion (RS256 → HS256)
What it is: the server expects RS256 and verifies the signature with a public key. An attacker signs the token with HS256, using the public key itself as the secret.
Why it works: if the verification code "takes the alg from the header and verifies with this key," then for HS256, the public key becomes the HMAC secret. And a public key is public for a reason.
How to fix: bind the key to the algorithm in the configuration. In Spring, NimbusJwtDecoder.withPublicKey(...) by default only accepts RS256 – I still specify the algorithm explicitly so it's visible in the code.
Test:
@Test
void hs256SignedWithPublicKeyIsRejected() throws Exception {
SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), validClaims().build());
jwt.sign(new MACSigner(PUBLIC_KEY.getEncoded()));
expect401(jwt.serialize());
}
3. Weak HS256 secret and brute-forcing
What it is: an attacker takes any of your tokens (their own, for example) and tries to brute-force secrets offline until the signature matches.
Why it works: brute-forcing doesn't require your server – just one token and a graphics card. Secrets like secret, changeme, or the project name are found in seconds.
How to fix: a random secret of at least 256 bits (e.g., openssl rand -base64 32), stored outside the code. If multiple services verify tokens – an asymmetric algorithm is better.
Test: this is a configuration issue, not a runtime one, so there's no 401 test here. Useful fact: the Nimbus library (which Spring Security uses) throws a KeyLengthException for HS256 secrets shorter than 256 bits. But length ≠ randomness: 32 characters from a dictionary will pass this check.
4. Injection via kid and jku
What it is: the kid (key ID) and jku (JWK Set URL) headers come from the client. An attacker specifies their server with their own key in jku or substitutes a file path or SQL fragment in kid.
Why it works: a server that loads keys from a URL in the token verifies the signature with the attacker's key. And kid, substituted into a database query or file path without filtering, is a classic injection.
How to fix: JWK Set URL – only from configuration. Use kid only as a key for searching among already known keys, never as a path or part of a query.
Test:
@Test
void foreignJkuIsIgnored() throws Exception {
KeyPair attacker = KeyPairGenerator.getInstance("RSA").genKeyPair();
JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256)
.jwkURL(URI.create("https://evil.example/jwks.json"))
.keyID("evil-key")
.build();
SignedJWT jwt = new SignedJWT(header, validClaims().build());
jwt.sign(new RSASSASigner(attacker.getPrivate()));
expect401(jwt.serialize());
}
5. Token substitution without checking aud and iss
What it is: a token, honestly issued for another service (or by another authorization server), is presented to your API.
Why it works: the signature is valid – services trust the same provider and key set. If the API doesn't check for whom the token is intended, it will accept any token.
How to fix: check iss (issuer) and aud (audience). In Spring, the issuer validator is enabled via JwtValidators.createDefaultWithIssuer(...), and aud needs to be added separately – configuration in the testing section.
Test:
@Test
void foreignAudienceIsRejected() throws Exception {
expect401(rs256(validClaims().audience("billing-api").build()));
}
@Test
void foreignIssuerIsRejected() throws Exception {
expect401(rs256(validClaims().issuer("https://evil.example").build()));
}
✅ Quick conclusion: all five attacks involve the server trusting the token more than its own configuration. The algorithm, keys, issuer, and audience must be fixed on the server.
📦 Attacks on Token Storage and Transmission
1. XSS + localStorage
What it is: an attacker's script (via XSS or a compromised npm dependency) reads a token from localStorage and sends it to their server.
Why it works: localStorage is accessible to any JavaScript within your domain.
How to fix: don't store tokens where JS can reach them. For SPAs, the most reliable option is the BFF (Backend for Frontend) pattern: tokens live on the server, the browser only receives an HttpOnly session cookie. Plus Content Security Policy against XSS itself.
💡 Honestly: HttpOnly doesn't make XSS safe – the script can still make requests on behalf of the user while they are on the page. But it won't be able to exfiltrate the token and use it for hours.
2. CSRF with tokens in cookies
What it is: a foreign website tricks the user's browser into sending a request to your API – and the browser automatically adds the cookie.
Why it works: cookies are attached to requests automatically, regardless of who initiated the request.
How to fix: SameSite=Lax or Strict + CSRF token for data-modifying requests. In Spring Security, CSRF protection is enabled by default. A token in the Authorization header is not vulnerable to CSRF – the browser doesn't add it itself.
3. Tokens in URLs, logs, and Referer
What it is: a token ends up in ?access_token=..., and from there – into access logs, browser history, analytics, and the Referer header when navigating to another site.
Why it works: URLs are logged by everyone – proxies, load balancers, CDNs. Nobody considers them secret.
How to fix: tokens only in the Authorization header; don't log this header; Referrer-Policy: strict-origin-when-cross-origin or stricter. In Spring, DefaultBearerTokenResolver by default doesn't accept a token from a query parameter – and I don't enable this option.
Test:
@Test
void tokenInQueryParamIsRejected() throws Exception {
mvc.perform(get("/api/me").param("access_token", rs256(validClaims().build())))
.andExpect(status().isUnauthorized());
}
✅ Quick conclusion: there is no perfect storage in the browser. The fewest risks are when there are no tokens in the browser at all (BFF), and if there are – in
HttpOnlycookies withSameSiteand CSRF protection.
🔀 Attacks on OAuth Flow
Here, the object of verification is not the API, but the authorization server. In the examples, I use Spring Authorization Server; for Keycloak or Auth0, the logic is the same – only the settings change.
1. Redirect URI substitution and open redirect
What it is: an attacker sends the user a link to authorize with their own redirect_uri. The user logs in – and the authorization code flies to the attacker.
Why it works: the server checks redirect_uri by prefix or wildcard (https://app.example.com/*). Then https://app.example.com/redirect?to=evil.example also matches – and if the site has an open redirect, the code "flows" further.
How to fix: exact character-by-character match with registered URIs – this is required by RFC 9700. And no open redirects in the application.
Test: a request for authorization with an unregistered URI should return an error, not a redirect:
curl -i "https://auth.example.com/oauth2/authorize?response_type=code\
&client_id=web-app&redirect_uri=https://evil.example/cb\
&code_challenge=...&code_challenge_method=S256"
# expect: 400 Bad Request, no Location header
2. Missing state and PKCE: CSRF and code injection
What it is: an attacker substitutes their own authorization code for the user. The user ends up logged into the attacker's account (login CSRF) – or the stolen code is exchanged for a token in a foreign client (code injection).
Why it works: without state, the client cannot prove that the response came from its request. Without PKCE, anyone with the code can exchange it for a token.
How to fix: PKCE with S256 method for all clients, even confidential ones. PKCE also closes CSRF at the redirect endpoint; I leave state as an additional layer. In Spring Authorization Server: ClientSettings.builder().requireProofKey(true).
Test: the same request, but without code_challenge – expect an invalid_request error.
3. Why implicit flow died
What it is: an old flow for SPAs where the access token was returned directly in the URL fragment (#access_token=...).
Why it's dangerous: a token in the URL means browser history, Referer, extensions, and also the inability to bind the token to the client. The same risk as in the URL section, only built into the protocol.
How to fix: authorization code + PKCE for all clients, including SPAs. Spring Authorization Server does not support implicit flow at all.
4. Mix-up attack
What it is: a client works with multiple authorization servers (e.g., "Sign in with Google" and "Sign in with X"). An attacker controls one of them and tricks the client into sending a code from a legitimate server to their token endpoint.
Why it works: the client doesn't check which server actually sent the response.
How to fix: the iss parameter in the authorization response (RFC 9207) or a separate redirect_uri for each provider. If the provider is the same – the attack is irrelevant, but it's good to know about.
✅ Quick conclusion: in OAuth, the main attack surface is redirects. Exact
redirect_uri, PKCE for all, and refusal of implicit flow close most real-world problems.