JWT and OAuth 2.0 Security: Attacks That Break Your API

Updated:
JWT and OAuth 2.0 Security: Attacks That Break Your API

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_uri checked "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 a 401.

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, and exp, a foreign token will pass.
  • ✅ In OAuth, redirects are the most dangerous. Exact redirect_uri match 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: 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.

JWTOAuth 2.0OpenID Connect (OIDC)
What it isToken formatAccess delegation protocolAuthentication 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?"
OutputSigned string header.payload.signatureAccess token (+ refresh token)ID token (always JWT) + access token
StandardRFC 7519RFC 6749 + RFC 9700OpenID Connect Core 1.0
Typical exampleSession token in a custom APIAn 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.

WhoWhat they can doWhat 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 HttpOnly cookies with SameSite and 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.

🔄 Refresh tokens: theft, rotation, reuse detection

What it is: A refresh token lives for days or weeks and grants the right to obtain new access tokens. A stolen refresh token means access to the account for its entire duration.

Why it's dangerous: The short exp of an access token is useless if an attacker can obtain new ones indefinitely.

How to fix it:

  • Rotation: Each exchange issues a new refresh token, and the old one becomes invalid. In Spring Authorization Server: TokenSettings.builder().reuseRefreshTokens(false).
  • Reuse detection: If someone presents an already used refresh token, it's a signal of theft. The server revokes the *entire family* of tokens, and the legitimate user will have to log in again. Check if your provider supports this; if you're writing your own auth server, implement it yourself.
  • Client binding (sender-constrained tokens): DPoP (RFC 9449) or mTLS — a token without the client's key is worthless. More complex, but the most reliable.
Refresh token rotation and reuse detection Access token expired, API responds with 401. Client exchanges refresh token RT1 for a new token pair: authorization server marks RT1 as used and issues an access token and RT2. If an attacker later presents the stolen RT1, the server detects reuse and revokes the entire token family, including RT2. Client API Auth Server 1. Normal refresh request with access token (expired) 401 Unauthorized POST /token grant_type=refresh_token + RT1 RT1 is valid → mark as used new access token + new RT2 repeat request with new access 200 OK 2. Someone presents old RT1 attacker (stole RT1 earlier) POST /token + RT1 RT1 already used! revoke entire token family — RT2 also becomes invalid 400 invalid_grant
Refresh token rotation: reusing an old token revokes the entire family

💡 Why reuse detection works: After theft, the token is with two parties — you and the attacker. One of you will use it second, and the server will notice. It doesn't know who the attacker is, so it revokes everything — that's the protection.

✅ Quick takeaway: a refresh token without rotation is a long-lived password. Rotation + reuse detection turn a theft into a one-time, noticeable event.

🪪 ID token ≠ access token: the most common confusion in OIDC

Both are often JWTs, both come from the same server — and they are constantly confused.

ID tokenAccess token
For whomFor the client (aud = client_id)For the API (aud = your service)
PurposeTo tell the client who logged inTo grant access to resources
Who readsThe clientOnly the API. The client should not parse it — it can be an opaque string
StandardOpenID Connect CoreOAuth 2.0

Common mistakes:

  • Frontend sends an ID token to the API instead of an access token — "because it contains the email."
  • API accepts any JWT from the provider with a valid signature, without checking aud.
  • Client builds permission logic on the content of an access token, which the provider can change at any time.

How to fix it: The API checks aud — and an ID token where aud equals client_id is automatically rejected.

Verification:

@Test
void idTokenIsRejectedByApi() throws Exception {
    expect401(rs256(validClaims().audience("web-app").build())); // aud = client_id
}

📜 OAuth 2.1 and RFC 9700: what has changed and what it means for you

RFC 9700 (January 2025) is the official Best Current Practice for OAuth 2.0 security. OAuth 2.1 is still a draft, but it doesn't add anything new — it consolidates OAuth 2.0 and these recommendations into one document. This means the rules are already in effect, regardless of the status of 2.1.

WhatWas in OAuth 2.0Now (RFC 9700 / OAuth 2.1)
PKCEFor mobile and public clientsRecommended for all (RFC 9700), mandatory in OAuth 2.1; S256 method
redirect_uriOften with a prefix or wildcardExact match
Implicit flowRecommended for SPAsNot recommended (RFC 9700), removed in OAuth 2.1
Password grantAllowedForbidden (RFC 9700), removed in OAuth 2.1
Refresh for public clientsNo requirementsRotation or client binding
Bearer token in query stringAllowed as an option (RFC 6750)Removed in OAuth 2.1

💡 What to do practically: If you have an implicit flow or password grant, that's your migration plan for the near future. If PKCE is enabled only for mobile, enable it for all. For the rest, check your provider's settings.

⚖️ When JWT is not needed and a regular session is better

The most reliable way to avoid JWT attacks is to not use JWT where it offers no advantages. After all the attacks above, I'll honestly say: for many projects, a server-side session is simpler and safer.

Choose a session if:

  • ☑️ Single backend and browser client — a classic website, admin panel, monolith with server-side rendering. The main advantage of JWT (verification without a shared store) is simply not needed here.
  • ☑️ Instant revocation is needed — banking, admin panels, "log out on all devices," user blocking. Deleting a session is one line of code; with JWT, it requires blacklists or token versions.
  • ☑️ Permissions change frequently. A session always reads current data, while a JWT carries roles that might be outdated.
  • ☑️ No external clients — mobile apps, partner APIs, SSO.

JWT is justified if:

  • ☑️ tokens are verified by several independent services;
  • ☑️ there are mobile or third-party clients;
  • ☑️ authentication is handled by an external provider (Keycloak, Auth0, Google) via OAuth/OIDC.

💡 Simple test: If you're still hitting the database on every request — to check a blacklist, token version, or current roles — you've ended up with a session with extra steps and new attack surfaces. In Spring Boot, a regular session is the default behavior of Spring Security, and for multiple instances, Spring Session with Redis is sufficient.

✅ JWT + OAuth Security Checklist for Production

API (resource server):

  • ☑️ Allowed algorithms are fixed in configuration
  • ☑️ JWKS keys and URLs are only from configuration; jku/x5u headers are ignored
  • ☑️ iss, aud, exp, nbf are checked
  • ☑️ Token is accepted only from the Authorization header
  • ☑️ The Authorization header does not end up in logs

Authorization Server:

  • ☑️ Exact match for redirect_uri
  • ☑️ PKCE (S256) is mandatory for all clients
  • ☑️ Implicit and password grants are disabled
  • ☑️ Access token lives for minutes
  • ☑️ Refresh token rotation + reuse detection
  • ☑️ HS256 secrets ≥ 256 bits or asymmetric keys; there's a key rotation plan

Client:

  • ☑️ Tokens are not in localStorage; BFF considered for SPAs
  • ☑️ Cookies: HttpOnly, Secure, SameSite + CSRF protection
  • ☑️ CSP and Referrer-Policy are configured
  • ☑️ Access token is sent to the API, not ID token
  • ☑️ No open redirects

🧪 How I test my API: a minimal set of checks (Spring Boot)

The idea is simple: for every attack from this article — a test that expects 401. If someone ever "simplifies" the security config, the tests will fail before an attacker notices.

Decoder configuration

@Bean
JwtDecoder jwtDecoder(@Value("${jwt.public-key}") RSAPublicKey publicKey) {
    NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey)
            .signatureAlgorithm(SignatureAlgorithm.RS256)   // only RS256
            .build();

    decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
            JwtValidators.createDefaultWithIssuer("https://auth.example.com"), // exp, nbf, iss
            new JwtClaimValidator<List<String>>("aud",
                    aud -> aud != null && aud.contains("orders-api"))      // aud
    ));
    return decoder;
}

💡 createDefaultWithIssuer checks exp, nbf (with a 60-second tolerance for clock skew), and iss. I add aud with a separate validator — it's the one most often forgotten.

Tests

The test key pair is located in src/test/resources, the public key is substituted via the jwt.public-key property. Tokens are created using the Nimbus JOSE + JWT library — it's already in the Resource Server dependencies.

@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = "jwt.public-key=classpath:test-key.pub")
class JwtSecurityTest {

    static final RSAPrivateKey PRIVATE_KEY = RsaKeyConverters.pkcs8()
            .convert(JwtSecurityTest.class.getResourceAsStream("/test-key.pem"));
    static final RSAPublicKey PUBLIC_KEY = RsaKeyConverters.x509()
            .convert(JwtSecurityTest.class.getResourceAsStream("/test-key.pub"));

    @Autowired MockMvc mvc;

    JWTClaimsSet.Builder validClaims() {
        return new JWTClaimsSet.Builder()
                .issuer("https://auth.example.com")
                .audience("orders-api")
                .subject("42")
                .expirationTime(Date.from(Instant.now().plusSeconds(300)));
    }

    String rs256(JWTClaimsSet claims) throws Exception {
        SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), claims);
        jwt.sign(new RSASSASigner(PRIVATE_KEY));
        return jwt.serialize();
    }

    void expect401(String token) throws Exception {
        mvc.perform(get("/api/me").header("Authorization", "Bearer " + token))
           .andExpect(status().isUnauthorized());
    }

    @Test // control: valid token passes
    void validTokenIsAccepted() throws Exception {
        mvc.perform(get("/api/me").header("Authorization", "Bearer " + rs256(validClaims().build())))
           .andExpect(status().isOk());
    }

    @Test
    void expiredTokenIsRejected() throws Exception {
        expect401(rs256(validClaims()
                .expirationTime(Date.from(Instant.now().minusSeconds(120))).build()));
    }

    // + tests from sections above: algNoneIsRejected, hs256SignedWithPublicKeyIsRejected,
    //   foreignJkuIsIgnored, foreignAudienceIsRejected, foreignIssuerIsRejected,
    //   tokenInQueryParamIsRejected, idTokenIsRejectedByApi
}

💡 Why a control test is mandatory: without it, all "expect 401" tests might pass for the wrong reason — for example, due to a broken key in the tests. If a valid token passes, and forged ones don't — the tests are actually proving something.

💡 I make the expired token 120 seconds in the past, not 1 second: otherwise, the 60-second tolerance will kick in, and the test will be unstable.

❓ Frequently Asked Questions (FAQ)

🔍 Is it enough to use a verified library?

No. The library correctly verifies what you told it to verify. aud, allowed algorithms, and key source are your configuration.

🔍 HS256 or RS256?

HS256 — if tokens are created and verified by a single service. RS256/ES256 — if multiple services or third-party clients verify them. The one that fits your architecture is more secure.

🔍 Is state needed if PKCE is present?

RFC 9700 allows relying on PKCE for CSRF protection if the authorization server supports it. I leave state as an additional layer — it costs almost nothing.

🔍 Where to store tokens in a SPA?

Ideally, nowhere: the BFF pattern keeps tokens on the server, and the browser only needs an HttpOnly cookie. If BFF is not possible — an HttpOnly cookie with SameSite and CSRF protection, but not localStorage.

🔍 Can permissions be checked by ID token?

No. The ID token is intended for the client to know who logged in. For API access — only the access token with the correct aud.

✅ Conclusions

  • 🎯 JWT cryptography isn't broken — trust in the token is broken. The server itself decides the algorithm, keys, issuer, and audience.
  • 🎯 OAuth is broken through redirects. Exact redirect_uri + PKCE for all.
  • 🎯 Refresh tokens are the main target. Rotation and reuse detection are mandatory.
  • 💡 Security that isn't verified by a test is an assumption. A dozen tests with an expected 401 protect the config from "temporary simplifications."

Sources: RFC 9700 — OAuth 2.0 Security BCP, RFC 8725 — JWT BCP, OAuth 2.1 (draft), RFC 7636 — PKCE, RFC 9207 — iss in authorization response, Spring Security — JWT Resource Server, PortSwigger — JWT attacks, OWASP JWT Cheat Sheet.