JWT and OAuth2: Stateless Authentication in Practice

bee2026-10-0855 min read0 views
Open up JWT's three segments, sign and verify by hand, design dual-token refresh, then walk the OAuth2 authorization-code flow — the standard auth stack for SPA + API.
1 / 147
Section
0. The 30-second version
2 / 147

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.

3 / 147

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.

4 / 147

Six terms, one line each (used throughout):

5 / 147
  • 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
6 / 147
类比|Analogy

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.

7 / 147
Diagram
Figure · Session (server remembers) vs JWT (client carries)
Figure · Session (server remembers) vs JWT (client carries)
8 / 147

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.

9 / 147
Animation
Animation · The OAuth2 authorization-code flow in seven steps
Animation · The OAuth2 authorization-code flow in seven steps
10 / 147

After this article you should be able to answer three questions:

11 / 147
  • 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_secret never appear in frontend code — and which hole opens if state is missing?
12 / 147
Section
1. Why JWT: the flaw of sessions first
13 / 147

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.

14 / 147
Table
DimensionSessionJWT
State storedServer side (memory / Redis)Client side (the token itself)
Horizontal scalingNeeds session sharing (sticky sessions or Redis)Stateless by nature; any node can verify
Logout / force-outEasy: delete the sessionHard: the token stays valid until expiry, needs a blacklist
RevocationReal-time controlOnly via expiry, or a blacklist/version
Per-request costOne storage lookupOne signature check, no I/O
Cross-originCookies are origin-boundCarried in a header; naturally CORS-friendly
15 / 147

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.

16 / 147
Section
2. Anatomy of a JWT: three segments, not encryption
17 / 147
Diagram
Figure 1 · The stateless auth path
Figure 1 · The stateless auth path
18 / 147

A real JWT is three Base64URL segments joined by dots:

19 / 147
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDAxIiwibmFtZSI6ImFsaWNlIiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImV4cCI6MTczMDAwMDkwMH0.9xQ2...
20 / 147

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:

21 / 147
Code
Codejson
// 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)
Notes

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).

22 / 147

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:

23 / 147
Diagram
Figure · Anatomy of a JWT: two plaintext segments, one seal
Figure · Anatomy of a JWT: two plaintext segments, one seal
24 / 147

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?

25 / 147
Section
2.1 What the signature actually protects
26 / 147

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.

27 / 147
Section
3. Signing and verifying: HS256 or RS256
28 / 147

Signing falls into two families; choosing wrong bites you in microservices:

29 / 147
Table
AlgorithmTypeKeyBest for
HS256Symmetric (HMAC)One shared key signs and verifiesA monolith that issues and verifies its own tokens
RS256Asymmetric (RSA)Private key signs, public key verifiesIssuer separate from many resource servers; the public key can be shared
30 / 147

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.

31 / 147

Below is a hand-written issue/verify utility with JJWT. First the dependencies:

32 / 147
xml
<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>
33 / 147

The full utility:

34 / 147
Code
Codejava
@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();    }}
Notes
  • signWith(key, HS256) fixes the algorithm; for RS256 sign with the RSAPrivateKey and verify with the RSAPublicKey.
  • parseSignedClaims both 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.
35 / 147
Section
4. Standard and custom claims
36 / 147

Part of the payload is standardized (RFC 7519), part is business-specific. Misuse causes security and compatibility pain:

37 / 147
Table
ClaimMeaningAdvice
issIssuerDistinguishes token sources across systems
subSubjectStore the user id, not the username
expExpiryAlways set it; shorter is safer for access tokens
iatIssued atUsed to decide whether a refresh is due
jtiUnique idThe blacklist key for logout / replay protection
roles / userIdCustom fieldsOnly authorization-relevant, non-sensitive data
38 / 147
Key point

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.

39 / 147
Section
5. Dual tokens: short access + long refresh
40 / 147

A single token has a dilemma: short means constant logouts, long means a bigger theft window. The industry's answer is dual tokens:

41 / 147
  • 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.
42 / 147
Animation
Animation · Dual-token lifecycle
Animation · Dual-token lifecycle
43 / 147

Four essentials of the refresh flow:

44 / 147
  1. Access expires; the server returns 401; the frontend interceptor silently calls /auth/refresh.
  2. The server verifies the refresh is valid and unrevoked, then issues a brand-new access + refresh pair.
  3. 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.
  4. Logout: write the refresh's jti into a Redis blacklist with a TTL equal to its remaining life; check the blacklist before accepting.
45 / 147
Code
Codejava
@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)));    }}
Notes
  • 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.
46 / 147

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:

47 / 147
Generator
GeneratorTick the configuration a stateless auth stack needsapplication.yml2 / 5
Tick only redis first for the minimal blacklist/rotation snippet; logging makes verification failures and blacklist hits visible; think about exposure before enabling actuator; profile shows how one file splits dev from prod — inject jwt.secret from env vars in production
Output
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 }
Why each choice matters
redisBoot 3 does not pool by default; lettuce.pool.* requires commons-pool2 on the classpath.
loggingLevels work per package; root=DEBUG floods you with third-party output — never in production.
48 / 147
Warning

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.

49 / 147
Section
6. The JWT filter: hooking tokens back into the security chain
50 / 147

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:

51 / 147
Code
Codejava
@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    }}
Notes
  • OncePerRequestFilter prevents 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.

52 / 147

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:

53 / 147
Stepper
StepperAn expired token's full walk through the filter1 / 6
Walk ① to ⑥: step ③ raises the exception and step ⑤ quietly makes it disappear — understand that and you understand how the wrong shape turns a 401 into a 500
Code under debug
1String header = request.getHeader("Authorization");
2if (header != null && header.startsWith("Bearer ")) {
3 String token = header.substring(7);
4 try {
5 Claims claims = jwtUtils.parse(token);
6 ...store into SecurityContext...
7 } catch (JwtException e) {
8 SecurityContextHolder.clearContext();
9 }
10}
11chain.doFilter(request, response);
Variables now
AuthorizationBearer eyJhbGci… (expired)
Call stack
—
1The filter runs once per request and starts by reading the Authorization header. Note this is only a read — nothing has been trusted yet.
54 / 147
Section
7. Attack surface and defenses
55 / 147
Table
AttackMechanismDefense
Algorithm confusion / noneAttacker sets alg to none, or swaps RS256 for HS256 using the public key as the secretServer fixes the expected algorithm, validates the alg header, disables none
Secret leakThe secret is hard-coded or committed to GitKeep secrets in a config center / env vars, rotate regularly
ReplayA captured valid token is reusedShort expiry + jti blacklist; add a one-time nonce for sensitive ops
XSS theftToken in localStorage is read by injected scriptStore in an HttpOnly cookie (re-enable CSRF), or enforce strict CSP
ForgeryA weak secret is brute-forcedUse a long key (HS256 ≥ 256 bits); prefer RS256
56 / 147

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:

57 / 147
Diagram
FlowHow many gates before a token reaches business code1 / 6
Click through ① to ⑥: each gate refuses in its own way — tell 'identity not established' apart from 'not enough privilege' before you fix anything
→
→
→
→
→
① Issue: the key stamps it
After a successful login the server digests header.payload with the key and writes the three-segment token. Section 3 covered the key-length rule: at least 256 bits for HS256, or the parser refuses outright.
All clearEach gate has its own failure signature: look at keys and tampering for a failed signature, clocks and expiry for a failed time check, replay and rotation for a blacklist hit; only a 403 sends you to role configuration.
58 / 147
Section
8. The OAuth2 authorization-code flow: don't confuse it with JWT
59 / 147

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.

60 / 147

The authorization-code flow is the safest mode for apps with a backend. Seven steps:

61 / 147
  1. The frontend redirects the user to the authorization server with client_id, redirect_uri, scope, state.
  2. The user logs in on the consent page and approves.
  3. The server redirects back to redirect_uri with a one-time code and the returned state.
  4. The backend exchanges code + client_secret for tokens (this happens server-side; client_secret never reaches the browser).
  5. The backend receives the access token (and possibly a refresh and an id token).
  6. The backend calls the resource server with the access token to fetch user info (e.g. GitHub /user).
  7. The backend maps that user to a local account and issues its own session or JWT — third-party login now authenticates your app.
62 / 147

Key config items for wiring third-party login:

63 / 147
Table
ConfigPurposeExample
client_idThe application identifierIv1.abc123
client_secretApp secret (backend only)****
authorization-uriWhere the user is sent to authorizehttps://github.com/login/oauth/authorize
token-uriWhere code is exchanged for tokenshttps://github.com/login/oauth/access_token
user-info-uriWhere user info is fetchedhttps://api.github.com/user
redirect-uriThe callback after authorizationhttps://app.example.com/login/oauth2/code/github
scopeRequested permissionsread:user user:email
64 / 147
Kernel lab
TeaVMInterception is verification: how the filter and proxy cooperateidle
Think about why JWT verification must happen before your business method runs
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
65 / 147
Section
9. Three traps that bite the hardest
66 / 147
Trap

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.

67 / 147
Trap

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.

68 / 147

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:

69 / 147
Animation
Animation · How random 401s happen
Animation · How random 401s happen
70 / 147

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.

71 / 147
Trap

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.

72 / 147
Decision
Decisionin an SPA, should the JWT live in localStorage or an HttpOnly cookie?
73 / 147
Section
10. Put a JWT in your hands
74 / 147

Sections 1-9 talked; this section lets you touch. Here is the analogy that should shape your first intuition about JWTs:

75 / 147
类比|Analogy

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).

76 / 147
Section
10.1 Issuing and verifying: one string, two fates
77 / 147

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:

78 / 147
Kernel lab
TeaVMSign a JWT yourself, then verify it yourselfidle
Use Issue to watch the three segments grow, then Verify to see exactly what the server compares
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
79 / 147

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.

80 / 147

Two more labs cover the places where production actually breaks; switch to the arguments named below:

81 / 147
Kernel lab
TeaVMWhich layer recognises your tokenidle
Pick Filter order to see at which hop the Bearer token becomes a SecurityContext, then 401 vs 403 to fix the boundary between 'not logged in' and 'not allowed'
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
82 / 147
Kernel lab
TeaVMWho actually wins for jwt.secretidle
Pick Who wins to watch command-line args, environment variables and application.yml override each other — a good share of production 'random 401s' is exactly one instance whose secret got overridden
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
83 / 147
Section
10.2 Tampering, expiry, refresh: three different failures
84 / 147

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:

85 / 147
Kernel lab
TeaVMWhat happens if you change one characteridle
Pick Tampered payload: quietly turn USER into ADMIN inside roles and watch verification blow up — this is the very principle behind Section 7's defence
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
86 / 147
Kernel lab
TeaVMExpiry is not a failure, it is the designidle
Pick Expiry: once exp passes, the request is rejected before your business code ever runs — which explains why accessToken lives only 15 minutes
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
87 / 147
Kernel lab
TeaVMThe whole dual-token rotationidle
Pick Refresh flow: the moment the old refresh yields a new pair it is blacklisted, so an attacker's copy dies after exactly one use
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
88 / 147

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:

89 / 147
Kernel lab
TeaVMHow a 401 body gets assembledidle
Pick @ControllerAdvice to see the uniform error payload, then 'Nobody handles → 500' — if your JWT filter throws and nothing catches it, the frontend just sees a bare 500
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
90 / 147

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:

91 / 147
Console
92 / 147
Tip

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.

93 / 147
Section
11. Sandbox: how long should an access token live?
94 / 147

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:

95 / 147
Sandbox
SandboxHow long should an access token live
Result
User experience: 401s barely noticeable (renew 2 minutes before expiry)
Refresh calls QPS: 0.9
Theft window of a leaked token: ≤ 15 minutes
Redis blacklist writes: 1.1k keys/hour
Audit note: a forced logout takes effect within at most 15 minutes
The common industry balance: short enough to cap the damage of a leak, long enough not to interrupt real work.
96 / 147
Note

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).

97 / 147

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):

98 / 147
Diagram
Figure · The stateless auth path
Figure · The stateless auth path
99 / 147
类比|Analogy

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).

100 / 147
Section
12. The OAuth2 authorization-code flow: a timeline for third-party login
101 / 147

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:

102 / 147
类比|Analogy

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.

103 / 147
Animation
Animation · The OAuth2 authorization-code flow in seven steps
Animation · The OAuth2 authorization-code flow in seven steps
104 / 147

Pin down the three details interviewers love, using the animation as reference:

105 / 147
Table
StepTypical questionAnswer
stateWhat breaks if you drop itThe callback becomes forgeable (CSRF): an attacker can make you log in as their account with their authorization result
codeWhy one-time and why via the browserIts leak window is seconds, and without the client_secret it cannot be turned into a token
Step 4Why can't the frontend swap the code itselfThen client_secret would have to ship inside JS where anyone can read it; this step belongs on the server only
106 / 147

The matching Spring Boot configuration (Boot 3 / Spring Security 6 — copy property names verbatim):

107 / 147
Code
Codeyaml
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/user
Notes

Tip: 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.

108 / 147

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:

109 / 147
Match
MatchSix terms, six roles in the chainMatched 0/6 · Missed 0
JWT and OAuth2 are different kinds of things; so are code and client_secret — both columns are shuffled, so don't guess by position
Pick a card on the left first
110 / 147
Section
13. Check yourself
111 / 147

A warm-up on the boundary that people remember backwards, straight from Section 2:

112 / 147
Quiz
Check yourselfWhat does the third segment (Signature) of a JWT actually guarantee?
Pick one — you get feedback right away
113 / 147

Now a combined question threading Section 5's rotation, Section 7's attack surface and the three labs of Section 10:

114 / 147
Quiz
Check yourselfThe refresh endpoint finds a refreshToken's jti already present in the Redis blacklist. Which statement is correct?
Pick one — you get feedback right away
115 / 147
Section
14. Common errors, searchable by exact wording
116 / 147

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.

117 / 147
Table
Error text (fragment)What really happened30-second fixRead 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 prefixPrint 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 instancesSection 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 allowsHave 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 plaintextThe 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 tokensDo 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 onceKey point of Section 4 · #41 Packaging & deploy security
A captured request shows a token with alg: "none" that still passed verificationYou 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 surfacePin the expected algorithm (JJWT: parseSignedClaims + verifyWith(key)), reject none explicitly, upgrade the jjwt dependencyAttack-surface table of Section 7
java.lang.IllegalArgumentException: Key length must be at least 256 bits for HS256Keys.hmacShaKeyFor() needs at least 32 bytes; a hand-written "secret123" is far too shortGenerate a random 32+ byte value (openssl rand -base64 48) and put it in configuration, not in codeUtility class of Section 3 · Level-1 exercise
invalid_grant / code_to_token_exchange_failed at the token-exchange stepThe code was already redeemed (it is single-use), it expired, or the redirect_uri is not byte-identical to the one in the authorize requestStart the authorization over for each login; diff the two redirect_uri values character by character in a text toolTimeline of Section 12
GitHub's callback page shows redirect_uri_mismatchThe 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 tooConfig block of Section 12
The same user works sometimes and gets a random 401; restarting the cluster "fixes" itInstances hold different secrets (different config sources), so a token only verifies on the node that signed itDistribute the key centrally, or switch to RS256: the private key stays with the issuer, resource nodes hold only the public keySecond trap of Section 9 · the security article preceding this one
The refresh endpoint intermittently returns "refresh token already used" and the user is kicked outRotation did its job: an already-blacklisted refresh was replayed — often two tabs each keeping a different old value in localStorage, occasionally genuine theftKeep this alert and audit by jti; give refresh one single storage location on the frontend, and coalesce concurrent 401s into one refresh callRotation in Section 5 · jwt lab, Refresh argument
118 / 147
Tip

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.

119 / 147

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:

120 / 147
Triage
Error triagejava.lang.NullPointerException
Why an expired token became a 500: the culprit is in your own package

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.

java.lang.NullPointerException: Cannot invoke "com.bee.order.domain.User.getId()" because "user" is null
at com.bee.order.web.OrderController.cancel(OrderController.java:41)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089)
at com.bee.order.security.JwtAuthFilter.doFilterInternal(JwtAuthFilter.java:71)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
Click the frame you blame — guessing is allowed
No pressure: guess the exception first, then which line actually made the call.
121 / 147
Section
15. Practice in three levels
122 / 147
Section
Level 1 · Follow along
123 / 147

Goal: about thirty lines that run through four outcomes — issue, verify, tamper, expire — leaving log evidence you can check line by line.

124 / 147

Step one, a minimal Maven project (only these three dependencies, Java 17):

125 / 147
xml
<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>
126 / 147

Step two, the class (src/main/java/com/example/jwt/JwtDemo.java):

127 / 147
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);    }}
128 / 147

Run main. Expected output — four branches, one outcome each (spacing may vary slightly):

129 / 147
text
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 match
130 / 147

Acceptance 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.

131 / 147
Section
Level 2 · Variants
132 / 147

Change exactly one thing per run and the conclusion flips:

133 / 147
  1. Swap .verifyWith(KEY) for a key that differs by only the last character. You will observe: every previously valid token now raises SignatureException, 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.
  2. 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.
  3. Hand-craft a token whose header is {"alg":"none","typ":"JWT"} with no third segment (Base64URL encode it and join as header.payload.), then send it to your endpoint. You will observe: modern JJWT rejects it inside parseSignedClaims with 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.
  4. Add throw new ServletException(e) inside JwtAuthFilter's catch (JwtException e). You will observe: even permitAll public endpoints start returning 500/401 — one line reproduces the trap stated in Section 6.
134 / 147

Tip: after variant 1, reopen the prop lab's "Who wins" argument; both should tell exactly the same story.

135 / 147
Section
Level 3 · Build one
136 / 147

Write yourself a "token health check" CLI (a plain main, no web layer needed) so any future JWT reveals its nature in three seconds.

137 / 147

Requirements:

138 / 147
  • 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 exp present, how large is the iat→exp gap, is sub numeric, and are there suspected sensitive keys (name contains password / phone / idCard / email / secret)
  • Verify with a supplied key and clearly separate three outcomes: pass / SignatureException / ExpiredJwtException; support --skew=60 to re-judge with 60 seconds of clock tolerance
  • Detect and reject alg=none and 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 as role=admin&isSuper=true
139 / 147

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.

140 / 147
Section
16. Self-check
141 / 147
Self-check

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".

142 / 147
Self-check

what are the three production triggers of SignatureException: JWT signature does not match, and which one shows up as random 401s in a cluster?

143 / 147
Self-check

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?

144 / 147
Self-check

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?

145 / 147
Self-check

why must step 4 of the authorization-code flow happen server-side? Which vulnerability opens if state is missing?

146 / 147

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.**

147 / 147
Summary

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.