JWT and OAuth2: Stateless Authentication in Practice
In plain words: authentication answers "who are you", authorization answers "what may you do, and may someone act on your behalf". JWT serves the first, OAuth2 the second — conflating them is the costliest misunderstanding in this article.
The classic setup keeps a ledger on the server after login (a session) and hands the browser a small ticket (JSESSIONID). The trouble starts at cluster scale: this request lands on node A, the next on node B, and B has never seen you. JWT flips where the ledger lives — identity is written into a token with an anti-forgery strip and carried by the client, so servers stop keeping books and only check the strip. The price is concrete: the token stays valid until it expires, so forcing a logout means maintaining your own blacklist.
Six terms, one line each (used throughout):
- Token: a self-proving credential string — a server reads it and knows who you are without touching the database
- Signature / verification: hash the content with a key to "stamp" it; verifying recomputes that hash with the same key, so one changed character breaks it
- Base64URL: an encoding (binary turned into URL-safe readable text), not encryption — anyone can decode it
- Claim: one field inside the payload, e.g.
sub= who,exp= when it dies - Bearer: the name of the HTTP auth scheme, and literally its rule — "whoever holds it gets in"
- Authorization code: OAuth2's one-time pickup slip, valid for seconds and useless as a key on its own
the whole article fits into one hotel. A session means the desk re-checks the guest register every time you enter your room (server-side books: safe but slow and hard to scale). A JWT means you get a laminated card printed with your room number and name, with a holographic strip on the back (the guard glances once and waves you through — but if you lose it, nobody can stop the finder before it expires, so you must report it and blacklist it). OAuth2 is a different errand entirely: you want a food-delivery app to open your safe. The desk will not hand over your room key; it gives that app a one-time pickup slip and tells it to see the manager out back — Section 12 walks that analogy all seven steps.

That comparison is the spine of this article: the left side is easy to control but hard to scale, the right side easy to scale but hard to revoke, and Section 5's dual tokens plus Section 11's sandbox exist precisely to patch the right side. The animation below parks the OAuth2 "pickup slip" route up front; come back to it when you reach Section 12.

After this article you should be able to answer three questions:
- When one character of a JWT's payload is changed, at which step does the server notice and by what mechanism?
- For "random 401s", should you suspect the algorithm first or mismatched per-instance keys? What is the cheapest way to tell?
- In third-party login, why must
client_secretnever appear in frontend code — and which hole opens ifstateis missing?
On a single machine, sessions are the easiest choice: after login the server keeps a session in memory or Redis and the browser holds a JSESSIONID cookie. The trouble starts at the cluster boundary: the next request may land on a node that has never seen that session.
| Dimension | Session | JWT |
|---|---|---|
| State stored | Server side (memory / Redis) | Client side (the token itself) |
| Horizontal scaling | Needs session sharing (sticky sessions or Redis) | Stateless by nature; any node can verify |
| Logout / force-out | Easy: delete the session | Hard: the token stays valid until expiry, needs a blacklist |
| Revocation | Real-time control | Only via expiry, or a blacklist/version |
| Per-request cost | One storage lookup | One signature check, no I/O |
| Cross-origin | Cookies are origin-bound | Carried in a header; naturally CORS-friendly |
One line of trade-off: sessions are easy to control but hard to scale; JWT is easy to scale but hard to revoke. For SPA + microservices, JWT's stateless nature usually wins.

A real JWT is three Base64URL segments joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDAxIiwibmFtZSI6ImFsaWNlIiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImV4cCI6MTczMDAwMDkwMH0.9xQ2...Split it and you get Header.Payload.Signature. The first two are merely Base64URL-encoded and anyone can decode them; here is each segment restored to JSON:
// Segment 1, Header: algorithm and type{ "alg": "HS256", "typ": "JWT" }// Segment 2, Payload: the actual claims{ "sub": "1001", // subject, usually the user id "name": "alice", "roles": ["ROLE_USER"], "iat": 1730000000, // issued at "exp": 1730000900, // expires in 15 minutes "jti": "a1b2c3d4" // unique token id, used for blacklisting}// Segment 3, Signature: the signature over the first twoHMACSHA256(base64Url(header) + "." + base64Url(payload), secret)Warning: Base64URL is encoding, not encryption. Paste the token into any online decoder and the whole payload is visible. Never put passwords, ID numbers or full phone numbers inside a JWT — it is tamper-proof (signed), not secret (unencrypted).
Laid flat, it becomes the anatomy chart below: three segments, three jobs — the first two are readable by anyone; only the third is the anti-forgery strip:

Walk the rule once more along the chart: join Header and Payload with a dot, digest the whole string to get the Signature; verifying recomputes that digest with the same key and compares. Section 2.1 then answers the more basic question — what exactly does that strip protect, and what does it not?
The signature proves the content was not tampered with: change sub or roles and it immediately fails to verify. But it does not keep the content secret, and it does not stop the token from being copied — whoever holds it can impersonate you. That's why JWT demands HTTPS and short lifetimes.
Signing falls into two families; choosing wrong bites you in microservices:
| Algorithm | Type | Key | Best for |
|---|---|---|---|
| HS256 | Symmetric (HMAC) | One shared key signs and verifies | A monolith that issues and verifies its own tokens |
| RS256 | Asymmetric (RSA) | Private key signs, public key verifies | Issuer separate from many resource servers; the public key can be shared |
The key difference: leak an HS256 secret and anyone can forge tokens; with RS256 only the authorization server holds the private key and resource servers verify with the public one — even a compromised resource server cannot forge tokens. Prefer RS256 for microservices and open platforms.
Below is a hand-written issue/verify utility with JJWT. First the dependencies:
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.5</version></dependency><dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.5</version> <scope>runtime</scope></dependency><dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.5</version> <scope>runtime</scope></dependency>The full utility:
@Componentpublic class JwtUtils { private final SecretKey key; // HS256 secret from config, at least 256 bits public JwtUtils(@Value("${jwt.secret}") String secret) { this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } /** Issue: standard claims + custom claims */ public String issue(Long userId, List<String> roles, Duration ttl) { Instant now = Instant.now(); return Jwts.builder() .subject(String.valueOf(userId)) .claim("roles", roles) .id(UUID.randomUUID().toString()) // jti for blacklisting .issuedAt(Date.from(now)) .expiration(Date.from(now.plus(ttl))) .signWith(key, Jwts.SIG.HS256) .compact(); } /** Verify: signature + expiry; throws on failure */ public Claims parse(String token) { return Jwts.parser() .verifyWith(key) .build() .parseSignedClaims(token) // bad signature or expiry throws JwtException .getPayload(); }}signWith(key, HS256)fixes the algorithm; for RS256 sign with theRSAPrivateKeyand verify with theRSAPublicKey.parseSignedClaimsboth verifies and checks expiry in one call — don't decode the payload yourself and compare timestamps, that's reinventing the wheel and easy to skip verification.
Part of the payload is standardized (RFC 7519), part is business-specific. Misuse causes security and compatibility pain:
| Claim | Meaning | Advice |
|---|---|---|
iss | Issuer | Distinguishes token sources across systems |
sub | Subject | Store the user id, not the username |
exp | Expiry | Always set it; shorter is safer for access tokens |
iat | Issued at | Used to decide whether a refresh is due |
jti | Unique id | The blacklist key for logout / replay protection |
roles / userId | Custom fields | Only authorization-relevant, non-sensitive data |
the never-put-in-JWT list — passwords or hashes, ID numbers, full phone numbers or emails, secrets, and anything whose leak directly costs you. A JWT is plainly visible.
A single token has a dilemma: short means constant logouts, long means a bigger theft window. The industry's answer is dual tokens:
accessToken: short (say 15 minutes), sent with every request, so a leak has a small window.refreshToken: long (say 7 days), used only to obtain a new access token, never for business requests.

Four essentials of the refresh flow:
- Access expires; the server returns 401; the frontend interceptor silently calls
/auth/refresh. - The server verifies the refresh is valid and unrevoked, then issues a brand-new access + refresh pair.
- Rotation: the old refresh is invalidated at once, so a stolen refresh works exactly once before the real user's next refresh fails and raises an alarm.
- Logout: write the refresh's
jtiinto a Redis blacklist with a TTL equal to its remaining life; check the blacklist before accepting.
@Servicepublic class TokenService { private final JwtUtils jwtUtils; private final StringRedisTemplate redis; /** Refresh: verify + rotate; the old refresh dies immediately */ public TokenPair refresh(String refreshToken) { Claims claims = jwtUtils.parse(refreshToken); // signature + expiry String jti = claims.getId(); if (Boolean.TRUE.equals(redis.hasKey("jwt:blacklist:" + jti))) { throw new BusinessException("refresh token already used"); // rotated or logged out } long remain = claims.getExpiration().getTime() - System.currentTimeMillis(); redis.opsForValue().set("jwt:blacklist:" + jti, "1", remain, TimeUnit.MILLISECONDS); Long userId = Long.valueOf(claims.getSubject()); List<String> roles = claims.get("roles", List.class); return new TokenPair( jwtUtils.issue(userId, roles, Duration.ofMinutes(15)), jwtUtils.issue(userId, roles, Duration.ofDays(7))); }}- The blacklist TTL must equal the token's remaining life, or Redis grows unbounded.
- After rotation the old refresh is blacklisted at once, single use only — the heart of dual-token security.
The blacklist, token lifetimes and where the key lives all end up in configuration. The generator below turns "the minimum rows stateless auth needs" into checkboxes: tick only Redis for the smallest blacklist-capable snippet, then layer on logging, Actuator and per-environment profiles — read them against the refresh code above:
server:
port: 8080
spring:
application:
name: demo-service
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: 6379
timeout: 3000ms
lettuce:
pool: { max-active: 16, max-idle: 8, min-idle: 2, max-wait: 2000ms }
logging:
level:
root: INFO
com.example.demoservice: DEBUG
org.springframework.jdbc.core.JdbcTemplate: DEBUG # 打 SQL 与参数
file:
name: logs/app.log
logback:
rollingpolicy: { max-file-size: 50MB, max-history: 14 }
Actuator's exposure.include: '*' opens /actuator/env too, where jwt.secret sits in plaintext. Hand-edit the generated actuator snippet into an allow-list and keep env and configprops out of it.
With a token in hand, you still need a filter to translate it into an identity Spring Security understands. Use OncePerRequestFilter so it runs once per request:
@Componentpublic class JwtAuthFilter extends OncePerRequestFilter { private final JwtUtils jwtUtils; public JwtAuthFilter(JwtUtils jwtUtils) { this.jwtUtils = jwtUtils; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); try { Claims claims = jwtUtils.parse(token); // signature + expiry Long userId = Long.valueOf(claims.getSubject()); List<String> roles = claims.get("roles", List.class); var authorities = roles.stream() .map(SimpleGrantedAuthority::new) .toList(); var auth = new UsernamePasswordAuthenticationToken(userId, null, authorities); SecurityContextHolder.getContext().setAuthentication(auth); // store identity } catch (JwtException e) { SecurityContextHolder.clearContext(); // invalid token: stay anonymous } } chain.doFilter(request, response); // keep going down the chain }}OncePerRequestFilterprevents double execution on async dispatch or internal forwards.- On parse failure just clear the context and don't throw, letting the authorization filter treat the caller as anonymous — so public endpoints stay reachable.
- This filter is attached via the previous article's
addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class).
Trap: don't return with a 401 inside the filter on an invalid token — that would also block permitAll public endpoints. Correct approach: stay anonymous, pass down the chain, and let the authorization filter decide.
The most easily missed part of the filter code above is the failure path: an expired token raises nothing here — it is silently "translated" into an anonymous identity and passed down the chain. The stepper below walks an expired token through the filter frame by frame — step ③ raises the exception and step ⑤ quietly erases it:
String header = request.getHeader("Authorization");if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); try { Claims claims = jwtUtils.parse(token); ...store into SecurityContext... } catch (JwtException e) { SecurityContextHolder.clearContext(); }}chain.doFilter(request, response);| Authorization | Bearer eyJhbGci… (expired) |
| Attack | Mechanism | Defense |
|---|---|---|
| Algorithm confusion / none | Attacker sets alg to none, or swaps RS256 for HS256 using the public key as the secret | Server fixes the expected algorithm, validates the alg header, disables none |
| Secret leak | The secret is hard-coded or committed to Git | Keep secrets in a config center / env vars, rotate regularly |
| Replay | A captured valid token is reused | Short expiry + jti blacklist; add a one-time nonce for sensitive ops |
| XSS theft | Token in localStorage is read by injected script | Store in an HttpOnly cookie (re-enable CSRF), or enforce strict CSP |
| Forgery | A weak secret is brute-forced | Use a long key (HS256 ≥ 256 bits); prefer RS256 |
Every row of the attack table can be caught at a different checkpoint. Here is the route a token takes from issuing to business code: signature, time, revocation, authorization — four gates, and missing any one opens an incident path. Poke each card to see which attack it answers:
The most important insight: OAuth2 is an authorization protocol; JWT is a token format — they are not the same thing. OAuth2 solves "how a third-party app accesses resources under a user's consent", and the token it issues may be a JWT or an opaque string.
The authorization-code flow is the safest mode for apps with a backend. Seven steps:
- The frontend redirects the user to the authorization server with
client_id,redirect_uri,scope,state. - The user logs in on the consent page and approves.
- The server redirects back to
redirect_uriwith a one-time code and the returnedstate. - The backend exchanges
code+client_secretfor tokens (this happens server-side;client_secretnever reaches the browser). - The backend receives the access token (and possibly a refresh and an id token).
- The backend calls the resource server with the access token to fetch user info (e.g. GitHub
/user). - The backend maps that user to a local account and issues its own session or JWT — third-party login now authenticates your app.
Key config items for wiring third-party login:
| Config | Purpose | Example |
|---|---|---|
| client_id | The application identifier | Iv1.abc123 |
| client_secret | App secret (backend only) | **** |
| authorization-uri | Where the user is sent to authorize | https://github.com/login/oauth/authorize |
| token-uri | Where code is exchanged for tokens | https://github.com/login/oauth/access_token |
| user-info-uri | Where user info is fetched | https://api.github.com/user |
| redirect-uri | The callback after authorization | https://app.example.com/login/oauth2/code/github |
| scope | Requested permissions | read:user user:email |
clock skew breaks verification. System clocks a few minutes apart across a cluster make a token issued on machine A look "not yet valid" or "expired" on machine B. Fix: enable NTP time sync, or set clockSkewSeconds in JJWT to tolerate small drift.
keys must be shared in a distributed setup. Each instance using a different secret means a token from instance A fails verification on instance B — showing up as "random 401s". Distribute the key centrally, or use RS256 so all nodes share the public key.
The most expensive trap in the "random 401" vocabulary deserves an animation — note the "restart fixed it" illusion at step ⑤; it fools people every day:

As you watch, line it up with the fix above: either distribute one jwt.secret to every instance, or move to RS256 so the private key stays with the issuer and every node shares the public key — "a restart fixed it" never means fixed; it means the request happened to land back on the instance that signed the token.
don't store tokens in Redis like a session and still claim statelessness. If you hit Redis for every request, you've built a "session backed by Redis" and thrown away JWT's benefit. A blacklist is a necessary exception, but it holds a revocation list, not every token.
Sections 1-9 talked; this section lets you touch. Here is the analogy that should shape your first intuition about JWTs:
a JWT is a laminated staff badge. The front prints photo, name and department (the payload); the back carries a holographic anti-forgery strip (the signature). The guard does not phone head office — he just checks whether the strip is intact and waves you in. That single picture explains both rules of JWT: ① a badge is meant to be read, so never print secrets on it (Base64URL is the lamination, not a safe); ② whoever holds the badge walks in, so lifetimes must be short and losses must be reported immediately (the blacklist).
Turn Section 2's three segments into buttons you can press. This lab really assembles header.payload.signature and then takes it apart again in front of you:
Run those two and you get the key insight: issuing and verifying use the same key, the same algorithm, and opposite directions. Signing = stamp a digest of the first two segments with the key; verifying = recompute that digest with the same key and compare stamps. The server knows who you are without ever querying the database — that is precisely the "one signature check, no I/O" row of Section 1's table.
Two more labs cover the places where production actually breaks; switch to the arguments named below:
Section 9 listed traps in theory; now act them out one by one. Note that these three fail at different points, and in production they raise three different alerts:
Finally, look through the exception lens. What shape does a failed verification take in the response? It depends on how far your exception resolvers reach:
Three failure scenes replayed; now retype them on the command line. This console is wired to the same Java kernel in your browser — start with whoami to confirm an anonymous identity, then lab jwt through issue, verify and tamper, and finish with lab sec deny for the 401/403 fork:
a pair worth typing side by side is lab jwt verify and lab jwt tamper — same endpoint, same code; one echoes an identity and the other blows up inside the verifier. That is the live version of row one in Section 7's attack table.
The access token's lifetime is the one purely trade-off parameter in JWT: short and users complain, long and attackers cheer. The sandbox turns it into a spring you can pull; each of the three positions shows four metric groups side by side — the landing point of everything above:
User experience: 401s barely noticeable (renew 2 minutes before expiry)Refresh calls QPS: 0.9Theft window of a leaked token: ≤ 15 minutesRedis blacklist writes: 1.1k keys/hourAudit note: a forced logout takes effect within at most 15 minutes
the numbers are illustrative, but two conclusions are real — the expiry decides your loss window after theft, not your user experience; experience is fixed by dual tokens and rotation. And the moment you want a long token to be revocable you must store every jti — at which point you are back in the session model (third trap of Section 9).
Now put that "identity travels with the client" route back onto a real request — the picture below is exactly where Section 6's filter code sits in the chain, and the two boxes to watch are "Client calls with Bearer token" and "JWT filter parses and verifies": the client attaches the token itself, so the server performs no "who is this person" lookup per request (Section 5's blacklist only asks "has this ticket been reported lost" — an exception, not a ledger):

a session is the library's loan desk — the book (your identity) is in your hands, but the desk's register is the authority; the librarian can strike a line at any moment (forced logout), at the price of every visitor making the librarian open the register once per visit (a storage lookup). A JWT is a ticket with its own anti-forgery sticker — the gatekeeper reads the ticket, never the register, so crowds never jam the door (easy to scale); but once someone copies your ticket, nobody can stop them before it expires (hard to revoke).
Section 8 gave the seven-step checklist; here it moves. Learn the analogy first and you will never recite the steps in the wrong order:
the authorization-code flow is the hotel desk opening your safe for a delivery app. You tell the app your room number (client_id) — on its own it opens nothing. The desk makes you type your password on the lobby screen and consent (the user logs in and grants at the authorization server), then hands the app a one-time pickup slip (the code). The app takes that slip to the manager out back (your backend swaps code + client_secret for a token), and only after checking credentials does the manager hand over the real key (the accessToken). Nobody ever passes your room card — your password — to the third party. That is what "authorization", not "authentication", means.

Pin down the three details interviewers love, using the animation as reference:
| Step | Typical question | Answer |
|---|---|---|
state | What breaks if you drop it | The callback becomes forgeable (CSRF): an attacker can make you log in as their account with their authorization result |
code | Why one-time and why via the browser | Its leak window is seconds, and without the client_secret it cannot be turned into a token |
| Step 4 | Why can't the frontend swap the code itself | Then client_secret would have to ship inside JS where anyone can read it; this step belongs on the server only |
The matching Spring Boot configuration (Boot 3 / Spring Security 6 — copy property names verbatim):
spring: security: oauth2: client: registration: github: client-id: ${GITHUB_CLIENT_ID} # from env vars, never hard-coded client-secret: ${GITHUB_CLIENT_SECRET} scope: read:user, user:email # request only what you need provider: github: authorization-uri: https://github.com/login/oauth/authorize token-uri: https://github.com/login/oauth/access_token user-info-uri: https://api.github.com/userTip: redirect-uri is not configured here — Spring Security derives it by convention as /login/oauth2/code/{registrationId}, and you must paste that value verbatim into your GitHub OAuth App settings. One trailing slash off and you get redirect_uri_mismatch — first aid in the next-but-one section.
This article is dense with vocabulary; what gets mixed up most is format versus protocol and credential versus scope. Play a round: click a term on the left, then its role in the chain — wrong picks get an explanation:
A warm-up on the boundary that people remember backwards, straight from Section 2:
Now a combined question threading Section 5's rotation, Section 7's attack surface and the three labs of Section 10:
Copy every snippet below straight into a search box — do not paraphrase or shorten it. Beginners stall in three places: verification failing, tokens expiring, and secrets being visible.
| Error text (fragment) | What really happened | 30-second fix | Read more in |
|---|---|---|---|
io.jsonwebtoken.security.SignatureException: JWT signature does not match locally computed signature. Validity cannot be determined. | One of three causes: ① the payload lost or changed a character; ② the verifying secret is not the signing one (instances differ, or one instance's config got overridden by an env var); ③ the frontend sent an extra space beyond the Bearer prefix | Print and compare a fingerprint of both keys server-side (MessageDigest SHA-256 — never log the raw secret), then confirm jwt.secret is identical across all instances | Section 10 prop lab · #38 Spring Security basics |
io.jsonwebtoken.ExpiredJwtException: JWT expired N seconds ago. Current time: ... Expiration: ... | Either the token genuinely expired (normal business), or cluster clocks drift more than the lifetime allows | Have the frontend intercept 401 and call refresh; enable NTP sync and tolerate small drift when parsing (clockSkewSeconds) | First trap of Section 9 · Section 11 sandbox |
| You paste the token into a Base64 decoder and see phone numbers / emails / internal fields in plaintext | The payload was never encrypted — Base64URL is reversible by anyone; if the secret is hard-coded and packaged into the jar, decompiling it also lets an attacker mint tokens | Do two things immediately: ① delete those fields, keep only ids and load the rest from the DB; ② move the secret out of Git history (config center / env var) and rotate it once | Key point of Section 4 · #41 Packaging & deploy security |
A captured request shows a token with alg: "none" that still passed verification | You are on an old parser that ignores the algorithm, or you hand-rolled parsing that compares the payload but never checks the signature — the classic algorithm-confusion attack surface | Pin the expected algorithm (JJWT: parseSignedClaims + verifyWith(key)), reject none explicitly, upgrade the jjwt dependency | Attack-surface table of Section 7 |
java.lang.IllegalArgumentException: Key length must be at least 256 bits for HS256 | Keys.hmacShaKeyFor() needs at least 32 bytes; a hand-written "secret123" is far too short | Generate a random 32+ byte value (openssl rand -base64 48) and put it in configuration, not in code | Utility class of Section 3 · Level-1 exercise |
invalid_grant / code_to_token_exchange_failed at the token-exchange step | The code was already redeemed (it is single-use), it expired, or the redirect_uri is not byte-identical to the one in the authorize request | Start the authorization over for each login; diff the two redirect_uri values character by character in a text tool | Timeline of Section 12 |
GitHub's callback page shows redirect_uri_mismatch | The callback registered in the OAuth App differs from the one the app actually sends (scheme, port or a trailing slash all count) | Add http://localhost:8080/login/oauth2/code/github verbatim in GitHub's settings — local debugging must be registered too | Config block of Section 12 |
| The same user works sometimes and gets a random 401; restarting the cluster "fixes" it | Instances hold different secrets (different config sources), so a token only verifies on the node that signed it | Distribute the key centrally, or switch to RS256: the private key stays with the issuer, resource nodes hold only the public key | Second trap of Section 9 · the security article preceding this one |
| The refresh endpoint intermittently returns "refresh token already used" and the user is kicked out | Rotation did its job: an already-blacklisted refresh was replayed — often two tabs each keeping a different old value in localStorage, occasionally genuine theft | Keep this alert and audit by jti; give refresh one single storage location on the frontend, and coalesce concurrent 401s into one refresh call | Rotation in Section 5 · jwt lab, Refresh argument |
the most frequent case here is not expiry but the pair "signature does not match" + "random 401" — nine times out of ten it is inconsistent keys across instances or a config-precedence clash. Watch the prop lab and the sandbox before you blame the algorithm.
Row one of the error table (SignatureException) is familiar by now, but production has a more deceptive pair — an expired token meeting a missing authorization rule disguises a 401 as a 500. The stack below is real; don't peek at the answer:
The user idled on the page for two hours, clicked 'Cancel order' and got a 500. The log holds neither a 401 nor an ExpiredJwtException, only a NullPointerException. Logging in again makes the same endpoint work instantly — find the culprit line first.
Goal: about thirty lines that run through four outcomes — issue, verify, tamper, expire — leaving log evidence you can check line by line.
Step one, a minimal Maven project (only these three dependencies, Java 17):
<dependencies> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency></dependencies>Step two, the class (src/main/java/com/example/jwt/JwtDemo.java):
package com.example.jwt;import io.jsonwebtoken.*;import io.jsonwebtoken.security.Keys;import java.nio.charset.StandardCharsets;import java.time.Duration;import java.time.Instant;import java.util.Date;import java.util.List;import java.util.UUID;import javax.crypto.SecretKey;public class JwtDemo { // In production read this from an environment variable; hard-coded for the demo, ≥ 32 bytes private static final SecretKey KEY = Keys.hmacShaKeyFor( "demo-secret-demo-secret-demo-secret-32b".getBytes(StandardCharsets.UTF_8)); private static String issue(Duration ttl) { Instant now = Instant.now(); return Jwts.builder() .subject("1001") .claim("roles", List.of("ROLE_USER")) .id(UUID.randomUUID().toString()) .issuedAt(Date.from(now)) .expiration(Date.from(now.plus(ttl))) .signWith(KEY, Jwts.SIG.HS256) .compact(); } private static void parse(String label, String token, SecretKey verifyKey) { try { Claims c = Jwts.parser().verifyWith(verifyKey).build() .parseSignedClaims(token).getPayload(); System.out.println(label + " -> OK sub=" + c.getSubject() + " roles=" + c.get("roles", List.class)); } catch (ExpiredJwtException e) { System.out.println(label + " -> ExpiredJwtException: " + e.getMessage()); } catch (SignatureException e) { System.out.println(label + " -> SignatureException: JWT signature does not match"); } catch (JwtException | IllegalArgumentException e) { System.out.println(label + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage()); } } public static void main(String[] args) { String good = issue(Duration.ofMinutes(15)); System.out.println("token segments = " + good.split("\\.").length); parse("(1) healthy token ", good, KEY); String[] parts = good.split("\\."); // Change the last character of segment 2 (the payload) — equivalent to editing roles String tamperedPayload = parts[1].substring(0, parts[1].length() - 1) + (parts[1].endsWith("A") ? "B" : "A"); parse("(2) tampered token ", parts[0] + "." + tamperedPayload + "." + parts[2], KEY); parse("(3) expired token ", issue(Duration.ofSeconds(-1)), KEY); // Verify the same token with a key that differs by one character: another instance's secret SecretKey otherKey = Keys.hmacShaKeyFor( "demo-secret-demo-secret-demo-secret-32C".getBytes(StandardCharsets.UTF_8)); parse("(4) wrong key ", good, otherKey); }}Run main. Expected output — four branches, one outcome each (spacing may vary slightly):
token segments = 3(1) healthy token -> OK sub=1001 roles=[ROLE_USER](2) tampered token -> SignatureException: JWT signature does not match(3) expired token -> ExpiredJwtException: JWT expired 1 milliseconds ago. ...(4) wrong key -> SignatureException: JWT signature does not matchAcceptance checklist: ① say which segment changed in case (2) and why that alone breaks verification (answer: segment 2 changed while segment 3 stayed put, so the recomputed digest no longer matches the carried one); ② shorten the signing key to 31 bytes and the program dies at Keys.hmacShaKeyFor with IllegalArgumentException: Key length must be at least 256 bits for HS256 — you have reproduced row 5 of the error table; ③ state which production alert cases (3) and (4) each correspond to.
Change exactly one thing per run and the conclusion flips:
- Swap
.verifyWith(KEY)for a key that differs by only the last character. You will observe: every previously valid token now raisesSignatureException, while startup and all other endpoints behave normally — the minimal reproduction of "inconsistent secrets across instances → random 401s". Fix in Section 9's second trap. - Make
Duration.ofSeconds(-1)the only access-token lifetime, then re-run the Section 11 sandbox. You will observe: refresh traffic jumps from 0.9 to 3.4 QPS and users complain of constant logouts. That is the counter-proof that expiry is about loss windows, not comfort. - Hand-craft a token whose header is
{"alg":"none","typ":"JWT"}with no third segment (Base64URL encode it and join asheader.payload.), then send it to your endpoint. You will observe: modern JJWT rejects it insideparseSignedClaimswith an algorithm error — but if you ever wrote "decode the payload and compare the timestamp myself" code, it would accept it. That gap is the algorithm-confusion surface of Section 7. - Add
throw new ServletException(e)insideJwtAuthFilter'scatch (JwtException e). You will observe: evenpermitAllpublic endpoints start returning 500/401 — one line reproduces the trap stated in Section 6.
Tip: after variant 1, reopen the prop lab's "Who wins" argument; both should tell exactly the same story.
Write yourself a "token health check" CLI (a plain main, no web layer needed) so any future JWT reveals its nature in three seconds.
Requirements:
- Given a token string, print all three segments decoded (Header / Payload as JSON, plus the signature's raw length)
- Print a standard-claims checklist: is
exppresent, how large is theiat→expgap, issubnumeric, and are there suspected sensitive keys (name containspassword/phone/idCard/email/secret) - Verify with a supplied key and clearly separate three outcomes: pass /
SignatureException/ExpiredJwtException; support--skew=60to re-judge with 60 seconds of clock tolerance - Detect and reject
alg=noneand anything outside HS256/RS256, printing a warning - Optional: compare role claims against a built-in allowlist (
ROLE_USER/ROLE_ADMIN) and highlight privilege-escalation strings such asrole=admin&isSuper=true
Acceptance checklist: ① run it on the token from Level 1 and the report shows a 15-minute exp gap; ② flip any middle character and the tool reports SignatureException rather than crashing; ③ feed it a token with no exp and the report flags that as high risk; ④ feed it an alg=none token and it must refuse before verification, explaining why; ⑤ not one line of business code changes.
name the three segments and what each is for, then explain why changing the payload always breaks verification — the answer must land on "the server recomputes the digest with the same key".
what are the three production triggers of SignatureException: JWT signature does not match, and which one shows up as random 401s in a cluster?
why is "Base64URL is encoding, not encryption" worth repeating? Beyond reading your source tree, what can an attacker do with a secret committed to Git?
what does rotation in dual tokens solve that a short expiry alone cannot? Which log line do you want to see when an old refresh gets replayed?
why must step 4 of the authorization-code flow happen server-side? Which vulnerability opens if state is missing?
Mantra: **three plaintext segments, one anti-forgery strip — sign by stamping, verify by recomputing; the short token caps the loss window, the long one buys continuity; the code is a pickup slip, and the secret never enters the browser.**
remember three sentences — a JWT is a tamper-proof plaintext token, so put nothing sensitive in it; dual tokens with rotation and a blacklist resolve the tension between "staying logged in" and "being revocable"; OAuth2 is an authorization protocol and JWT is a token format — first decide whether you need authentication or third-party delegation. With these clear, you can choose correctly among sessions, JWT and OAuth2.