JWT Authentication with Spring Security
Why Basic Auth resends credentials every time and how a stateless token fixes it. What a JSON Web Token is, its header/payload/signature and how the signature buys trust, the end-to-end auth flow, wiring JWT into Spring Boot, minting a token at login, validating it on every request, and role-based authorization. Includes microexercises and interview Q&A.
Assumes you know
After this you should be able to
- Explain JWT verification
- Wire a bearer-token filter
How to stop shipping a username and password on every request and instead hand the client a signed, self-expiring ticket the server can trust without remembering anything.
Choose your route#
Problem: a stateless API needs to recognize a caller without resending a password on every request. Budget 45 minutes to wire a safe token boundary or 15 minutes to revise the security explanation.
Recall first: where does authentication happen in a filter chain, and why does a signature need a key? Basic Auth helps, but this page does not lock you out.
- Explain JWT verification.
- Wire a bearer-token filter.
- State the security limits of a signed token.
Shared core: a signed claim is not a session#
A JWT carries a header, claims, and a signature. The server verifies the signature and validates expiry before constructing an authenticated request; it must not trust a decoded payload merely because it is readable.
Closest confusion: encoding is reversible formatting; signing detects tampering; encryption hides content. A JWT is commonly signed, not encrypted.
Failure trace: decoding a token and reading its roles without verifying the signature lets a forged payload impersonate privileges. Signature validation and expiry checks must precede authorization.
Decision rule: use short-lived signed access tokens only with key management, issuer/audience validation where appropriate, and a revocation or refresh strategy that matches the risk.
Version note: examples were reviewed against Spring Boot 4.x and current Spring Security conventions on 2026-08-28; verify your JWT library’s current API before implementation.
Learn deeply: add a verified bearer boundary#
Mission (30 minutes): add login token issuance and a filter that verifies a bearer token before placing an identity in the security context. Likely files: token service, authentication filter, security configuration, and tests.
Acceptance checks: a valid token reaches a protected endpoint; an expired or altered token is rejected; the filter never accepts claims before signature validation; no secret is committed or logged.
- Centralize key loading and token verification in one service.
- Keep the filter focused on extraction, verification, and context creation.
- Test valid, expired, missing, and tampered tokens separately.
Solution shape, not a script
Let authentication create a minimal principal. Authorization remains in route policy, so token parsing does not become a second application layer.
Transfer: add a key-rotation plan and explain how clients survive a new signing key without making every token permanent.
Prepare for interviews: explain token trust#
Two-minute summary: JWTs make request authentication stateless only when a server verifies the signature and time-based claims before creating an identity. They move, rather than erase, operational trade-offs.
Explain in 60 seconds: why is decoding a JWT not authentication?
Primary question: walk through bearer-token validation in Spring Security. Follow-ups: how do you revoke a token, and where should roles be enforced?
Security exercise: an API trusts the role in a base64-decoded token and accepts an attacker’s edited ADMIN claim. Explain the exploit, then design the minimum verification sequence.
Strong-answer outline
Separate parse, signature verification, claim validation, and authorization. Mention expiry, keys, and revocation trade-offs without promising that JWTs solve every session problem.
Timed mock: design an access-token and refresh-token policy for a consumer API in 90 seconds.
Exit check#
- What does a JWT signature prove?
- Why is decoding insufficient?
- Which claim limits token lifetime?
- Where should bearer verification run?
- What operational problem does key rotation solve?
Learn next: Use explain jwt verification as the base, then explain a layered request path in Core concepts retrieval review. Interview next: Use explain jwt verification as the base, then persist data with jpa in Real data with JPA, H2 & Lombok.
Continue this path · Switch paths from this topic · Review first: authentication and authorization
Core concepts checkpoint#
Pause before Applied skills. Connect the route, dependency, persistence, boundary, transaction, and security decisions in one request rather than reciting annotations.
- Explain where request validation ends and a business rule begins for a TaskFlow update.
- Describe one operation that needs a service transaction and the partial state it prevents.
- Walk through why a decoded JWT cannot authorize a request until signature and expiry checks pass.
Basic Auth resends your raw credentials on every call and can never forget them. JWT flips this: log in once, receive a signed token that carries its own identity and expiry, and present it on each request. The server verifies the signature with a secret it holds — no session store, no password on the wire.
Notebook Summary#
§ Basic Auth — why it fails#
- BasicAuth → header
Authorization: Basic base64(user:pass) - Base64 = encoding, NOT encryption → decoded instantly ⚠
- sends creds on every request → more exposure + overhead
- no expiry · no logout · no rate-limit · no session mgmt
- OK for starter/internal + HTTPS · never production
§ JWT — what it is#
- JWT = JSON Web Token → signed JSON passed between parties
- 4 traits: stateless · self-contained · compact · signed (HMAC/RSA)
- shape:
header.payload.signature— 3 Base64URL parts, 2 dots - header = {alg, typ} · payload = claims (sub, iat, exp) · sig = keyed hash
- ✎ doodle: [ H ] · [ P ] · [ S ] — three boxes, two dots between
§ Signature & trust#
- sig = HMAC-SHA256( base64url(header)+"."+base64url(payload), secret )
- server recomputes sig → compares → tamper = mismatch ⭐
- payload is readable by anyone → never put passwords/secrets in it ⚠
- secret lives only on server → client can read but can't forge
§ Auth flow#
- login (creds once) → server verifies → issues JWT
- client stores token → sends
Authorization: Bearer <jwt>each call - filter checks sig + expiry → sets SecurityContext → controller runs
- ✎ doodle: client ⇄ server; → "login=token", ← "Bearer=resource"
§ Spring wiring#
- dep: jjwt (api + impl + jackson) · spring-boot-starter-security
- JWTService: generateToken() · extractUsername() · validateToken()
- JwtFilter extends OncePerRequestFilter → addFilterBefore UsernamePasswordAuthenticationFilter
- SecurityConfig: csrf off · session STATELESS · permit /login · authorize rest
§ Role-based authz (RBAC)#
.requestMatchers("/admins").hasRole("ADMIN")/USER → ok ·/adminsUSER → 403 · ADMIN → ok- 401 = not authenticated · 403 = authenticated but not allowed 💡
| Basic Auth | JWT | |
|---|---|---|
| sends | creds every req | token every req |
| expiry | none | exp claim |
| server state | none (re-auth) | none (stateless) |
| revoke | hard | let it expire |
§ Postman test#
- Auth tab → Bearer Token → paste JWT → sent on every req
- decode/verify at
jwt.io: paste token + Base64 secret - wrong creds on /login → no token
Detailed Study Notes#
Alex, a developer at a small firm, wrapped his new customer portal in Basic Authentication because it was the quickest thing to enable. One Friday he logged in over café Wi-Fi. Someone watching that network copied the one header his browser attached to every request, pasted it into a decoder, and read his email and password in plain text. Over the weekend they browsed customer accounts and created their own admin login. There was no token to expire, no session to kill — the team eventually reset every password just to lock the intruder out. The whole disaster traces back to one design choice: how the credentials travelled.
2.1Why Basic Authentication is not enough#
Basic Authentication is a scheme where the client attaches the username and password to every request, encoded but not encrypted. When you use it, the browser builds one header and repeats it on every call:
Authorization: Basic YWxpY2VAY3Jpb2RvLmNvbTphbGljZTEyMw==That trailing blob looks scrambled, but it is only Base64 encoding of the literal string username:password. Encoding is a reversible format change, not a lock — anyone can decode it.
The example above is exactly alice@criodo.com:alice123. Paste it into any Base64 decoder
and the password falls out.
Base64 is writing your PIN in a mirror. It looks unreadable for a second, but anyone who holds it up to a mirror reads it instantly. It slows nobody down. Only the transport tunnel — HTTPS — actually hides it, and Basic Auth gives you nothing if that tunnel is missing or stripped.
The deeper problem is not just interception. Basic Auth has no memory and no lifecycle:
Base64 is trivially decoded; over plain HTTP the password is effectively in the clear.
Once captured, credentials are valid forever — there is no built-in "this expires at 6pm".
Nothing to invalidate. Killing access often means a full password reset for everyone.
No native rate limiting or lockout, so automated password guessing runs unchecked.
And because the credentials ride along on every single request, every request is another chance to leak them, plus needless overhead. Basic Auth is fine for a throwaway demo behind HTTPS; it should never guard a production system.
"It's over HTTPS, so Basic Auth is safe" is the trap. HTTPS protects the tunnel, but the credential itself never expires and is replayed on every call — one leaked log, misconfigured proxy, or stripped TLS and the attacker owns the account indefinitely. The fix is not more encoding; it is a credential with a built-in lifetime.
2.2What a JSON Web Token actually is#
A JSON Web Token (JWT) is a small, signed packet of JSON that proves who you are without the server having to look you up. Instead of resending your password, you log in once, and the server hands back a token. From then on you present the token; the server trusts it because it carries a signature only the server could have produced.
Four properties are worth memorising because interviewers ask for them by name:
The server stores nothing about your session. It doesn't need a session table — everything it needs is in the token.
The token itself carries the identity and claims (who, issued-when, expires-when), so no extra database round-trip.
It's a short URL-safe string, so it fits in an HTTP header, a query parameter, or a POST body.
A cryptographic signature (HMAC or RSA) lets the server detect any tampering. Change one byte and the signature no longer matches.
A JWT is a festival wristband. At the gate you show ID once (login); they clip on a wristband stamped with your access level and an end date. Inside, no booth re-checks your ID — they glance at the wristband. It's tamper-evident (you can't fake the stamp), it expires on its own, and the gate keeps no list of who's inside.
2.3Anatomy: header, payload, signature#
Every JWT is three Base64URL-encoded chunks joined by dots: header.payload.signature.
Decode the two dots and you can read the first two parts with the naked eye — they are not secret.
Take a real token from this lesson and decode it. The header names the algorithm:
{ "alg": "HS256" }The payload holds the claims — statements about the user. Here sub is
the subject (who the token is about), iat is "issued at", and exp is "expires at", both as Unix seconds:
{
"sub": "abdulr@criodo.com",
"iat": 1729342240,
"exp": 1729428640
}Subtract iat from exp and you get 86,400 seconds — exactly 24
hours of validity baked into the token itself. That single number is the thing Basic Auth never had.
The payload is encoded, not encrypted. Anyone holding the token can Base64-decode the claims and read them. Never place a password, a card number, or any secret in a JWT payload. The signature stops tampering; it does not hide contents.
At jwt.io you can paste a token and watch it split into the three colour-coded parts. Paste the server's Base64 secret into the "verify signature" box and the site recomputes the signature to tell you whether the token is authentic — exactly what the server does on every request.
2.4The authentication flow, end to end#
The flow has two phases: get a token once, then present it on every later request. Keeping these two phases separate in your head is most of the battle.
The verification step never touches a database of sessions. The server recomputes the signature over the received header and payload using its secret, and compares it to the signature in the token. Match means untampered and authentic; mismatch means rejected. This is why JWT is called stateless.
Server remembers you#
- Session id in a cookie; data in a server store
- Every request → lookup in that store
- Logout = delete the session row
- Scaling needs shared/sticky sessions
Token remembers itself#
- Identity + claims live inside the token
- Every request → verify signature, no lookup
- Logout = client drops it / wait for exp
- Any server with the secret can verify
Statelessness cuts both ways: because the server keeps no record, you cannot instantly "log out" a JWT the way
you delete a session. Until it expires, a stolen token works. That is why exp is kept
short and why real logout needs an extra mechanism (a short lifetime plus a denylist or refresh tokens).
2.5Setting up JWT in Spring Boot#
Spring Security gives us the filter chain and role checks; a JWT library gives us the token building and parsing.
The lesson uses jjwt (the io.jsonwebtoken library). [+] The exact coordinates below reflect the current jjwt release; add all three so signing
and parsing work at runtime:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>The heart of the setup is a JWTService — one class that owns the secret and knows how to
mint and read tokens. The signing key is a shared secret; the token in this lesson uses HS256
(HMAC with SHA-256), a symmetric algorithm where the same secret both signs and verifies.
The lesson showed a neat way to avoid hard-coding that secret — generate a fresh one in the constructor:
private String secretkey = "";
public JWTService() {
try {
KeyGenerator keyGen = KeyGenerator.getInstance("HmacSHA256");
SecretKey sk = keyGen.generateKey();
secretkey = Base64.getEncoder().encodeToString(sk.getEncoded());
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}Generating the key in the constructor means a new secret on every restart, so all previously issued tokens
instantly become invalid — handy in development. In production you want a stable secret (from application.properties, an env var, or a secrets manager) so tokens survive restarts and
multiple instances can verify each other's tokens.
[+] With the key in place, building a token is a fluent chain: set the claims, subject,
issued-at and expiry, then sign and compact into the dotted string. Reading it back decodes the key from Base64
into a real SecretKey:
public String generateToken(String username) {
Map<String, Object> claims = new HashMap<>();
return Jwts.builder().claims(claims).subject(username).issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24)).signWith(getKey()).compact();
}
private SecretKey getKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretkey);
return Keys.hmacShaKeyFor(keyBytes);
}Whatever transform you use to store the key, use the identical inverse to read it. If you
Base64-encode the key but later sign with the raw string bytes, the signing key and the verifying key differ,
and every parse throws SignatureException: JWT signature does not match. This exact
mismatch is drilled in Exercise 2.
2.6Generating the token in the login API#
The login endpoint is the only place credentials appear. It hands the username and password to
Spring's AuthenticationManager; if they check out, it asks the JWTService for a token and returns it. After this, the password is never sent again.
@RestController
public class AuthController {
@Autowired private AuthenticationManager authManager;
@Autowired private JWTService jwtService;
@PostMapping("/login")
public String login(@RequestBody User user) {
Authentication auth = authManager.authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword()));
if (auth.isAuthenticated()) {
return jwtService.generateToken(user.getUsername());
}
return "login failed";
}
}2.7Validating the token on every request#
Reading a token safely means three things: pull the username out, confirm the signature, and confirm it hasn't
expired. jjwt's parser does the heavy lifting — verifyWith(getKey()) checks the
signature, and it throws automatically on an expired or malformed token.
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
private<T> T extractClaim(String token, Function<Claims, T> resolver) {
return resolver.apply(extractAllClaims(token));
}
private Claims extractAllClaims(String token) {
return Jwts.parser().verifyWith(getKey()).build().parseSignedClaims(token).getPayload();
}
public boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isExpired(token);
}
private boolean isExpired(String token) {
return extractAllClaims(token).getExpiration().before(new Date());
}A filter is a checkpoint every request passes through before it reaches a controller. We add one
that runs early in Spring Security's chain, reads the Authorization header, and — if a
valid Bearer token is present — tells Spring "this request is authenticated as this user" by populating the SecurityContext.
The filter is the wristband scanner at every inner door. It doesn't re-check your ID; it glances at the band, confirms the stamp is genuine and the date hasn't passed, and waves you through. No band, or a fake one, and you simply proceed as an anonymous guest — who then gets turned away at any door that requires a band.
@Component
public class JwtFilter extends OncePerRequestFilter {
@Autowired private JWTService jwtService;
@Autowired private ApplicationContext context;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
String token = null, username = null;
if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
username = jwtService.extractUsername(token);
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = context.getBean(MyUserDetailsService.class).loadUserByUsername(username);
if (jwtService.validateToken(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}The header value is Bearer <token> — the word "Bearer", one space, then the
token. Forgetting substring(7) passes the literal string "Bearer eyJ..." into the
parser and it fails. And always call filterChain.doFilter(...) at the end, even when
there is no token, or the request dies silently in the filter and never reaches your controller.
2.8Securing endpoints with role-based authorization#
Authentication asks "who are you?"; authorization asks "are you allowed?" Once the filter has
established who you are, Spring Security decides which endpoints your role may touch. This is configured in one
SecurityConfig bean.
Three settings matter for JWT. Turn off CSRF (it defends cookie-based sessions we no longer use), mark the session policy stateless so Spring never creates a server session, and register our filter before the built-in username/password filter so our token is read first.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired private JwtFilter jwtFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http.csrf(c -> c.disable()).authorizeHttpRequests(req -> req.requestMatchers("/login", "/register").permitAll().requestMatchers("/admins").hasRole("ADMIN").anyRequest().authenticated()).sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class).build();
}
}Now the roles do their job. A token minted with role USER reaches / but is refused at /admins; a token with role ADMIN reaches both.
| Request | Role USER | Role ADMIN |
|---|---|---|
GET / | 200 OK | 200 OK |
GET /admins | 403 Forbidden | 200 OK |
POST /login (valid) | 200 OK → returns a JWT | |
POST /login (bad creds) | 401 · no token issued | |
Know the difference cold: 401 Unauthorized means "I don't know who you are" (missing or invalid token); 403 Forbidden means "I know who you are, but you're not allowed here" (valid token, wrong role). Interviewers love this distinction, and it's the fastest way to debug a secured endpoint.
hasRole("ADMIN") silently expects the authority to be stored as ROLE_ADMIN — Spring prepends the ROLE_ prefix for you. If you
build authorities without that prefix, use hasAuthority("ADMIN") instead, or the check
never matches.
2.9Testing the secured endpoints in Postman#
To call a protected route, Postman must attach the token the way the filter expects. In the request's
Authorization tab, choose type Bearer Token and paste a token you got from /login. Postman then adds the Authorization: Bearer <jwt>
header to every send.
- Log in first (
POST /loginwith valid credentials) and copy the returned token. - Paste it as the Bearer token, then hit
GET /— a USER token succeeds. - Swap in a USER token and hit
GET /admins— expect 403; swap in an ADMIN token — expect 200. - Paste the token into jwt.io with the Base64 secret to confirm the signature verifies and read the claims.
2.10Interview & practice Q&A#
Why is Base64 encoding not a security measure?
Base64 is a reversible way to represent binary or text as URL-safe characters — a format change, not a
cipher. There is no key involved, so anyone can decode it with a one-line tool. In Basic Auth it means username:password is effectively in the clear to anyone who reads the request.
What are the three parts of a JWT and which ones are secret?
Header, payload, and signature, joined by dots. The header and payload are only Base64URL-encoded, so anyone can read them — nothing there is secret. Only the signature offers protection: it's a keyed hash that lets the server detect tampering, but it hides nothing.
What does "stateless" mean, and what does it cost you?
Stateless means the server stores no session data; the token carries everything needed to authenticate, so each request is verified by signature alone. The cost is revocation — since there's no session to delete, you can't instantly invalidate a token. You mitigate this with short expiry times plus refresh tokens or a denylist.
Why disable CSRF and set the session policy to stateless for JWT?
CSRF protection defends against forged requests that ride on cookies the browser sends automatically. JWTs are sent explicitly in the Authorization header, not auto-attached like cookies, so that attack vector doesn't apply and the protection is unnecessary overhead. Stateless session policy tells Spring never to create a server-side session, keeping the app truly stateless.
What is the difference between a 401 and a 403 response?
401 Unauthorized means authentication failed or is missing — the server doesn't know who you are, usually a missing or invalid token. 403 Forbidden means authentication succeeded but authorization failed — you're a known user without the required role for that endpoint.
Why register the JWT filter before UsernamePasswordAuthenticationFilter?
So the token is read and the SecurityContext is populated before Spring's default form-login filter runs. If our filter ran after, the request could be treated as unauthenticated and rejected before the token was ever examined. Order in the filter chain determines who authenticates the request.
Should sensitive data ever go into a JWT payload?
No. The payload is encoded, not encrypted, so anyone holding the token can decode and read every claim. Put only non-sensitive identity data there — a subject, roles, timestamps — and never a password, card number, or private detail.
How would you implement logout with stateless JWT?
Because the server keeps no session, you can't simply delete one. Practical options: keep the access-token lifetime short and rely on it expiring, maintain a server-side denylist of revoked token ids checked on each request, or use short access tokens paired with revocable refresh tokens. The client also drops its copy so it stops sending it.
What happens if the signing secret differs between issuing and verifying?
Verification recomputes the signature with the verifying key and compares it to the token's signature. If the
keys differ — even by an encoding mismatch — the computed signature won't match and jjwt throws a SignatureException. Every otherwise-valid token gets rejected, which is exactly why the
store/read transforms on the key must be symmetric.
2.11Key takeaways#
- Basic Auth resends decodable credentials on every request, with no expiry, logout, or rate limiting — unfit for production.
- A JWT is a signed, self-contained JSON token:
header.payload.signature, stateless and compact. - The signature proves authenticity and detects tampering; the payload is readable, so keep secrets out of it.
- Log in once to get a token, then send
Authorization: Bearer <jwt>on each request. - In Spring: a
JWTServicemints/reads tokens, aOncePerRequestFiltervalidates them, andSecurityConfigenforces stateless sessions and role rules. - 401 = unknown identity; 403 = known identity, insufficient role.
Section 3
Microexercises#
3.1Build it — mint an HS256 token#
Write a generateToken(String username) method and its getKey() helper for the JWTService. The stored secret is a Base64
string field named secretkey.
Requirements:
- Put the username in the
sub(subject) claim. - Set
iatto now andexpto 24 hours from now. - Sign with HS256 using the decoded secret and return the compact dotted string.
Expected result: a three-part header.payload.signature string whose
payload decodes to {"sub":"...","iat":...,"exp":...} with exp − iat = 86400.
Show the full walkthrough
Step 1 — turn the stored secret into a key. The field holds Base64, so decode it back to
bytes and wrap those bytes as an HMAC-SHA key. Decoding (not raw .getBytes()) is what
makes verification symmetric later.
Step 2 — build the claims. Use the fluent Jwts.builder(): subject(username) sets sub, issuedAt
sets iat, and expiration sets exp.
Step 3 — do the 24-hour math in millis. 1000 * 60 * 60 * 24 =
86,400,000 ms added to "now". Since iat and exp are stored
as seconds, their difference is 86,400.
Step 4 — sign and compact. signWith(getKey()) produces the
signature; compact() serialises the three parts into the dotted string.
public String generateToken(String username) {
Map<String, Object> claims = new HashMap<>();
return Jwts.builder().claims(claims).subject(username).issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24)).signWith(getKey()).compact();
}
private SecretKey getKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretkey);
return Keys.hmacShaKeyFor(keyBytes);
}Why: everything the server later needs to trust you — who you are and when the token dies — is written into the token and sealed by the signature. That is the whole self-contained, stateless idea in one method.
3.2Diagnose it — every token is rejected#
A teammate reports that login works and returns a token, but every protected request fails and the parser throws. The secret is stored Base64-encoded. Read the two methods, predict the exact exception, name the root cause, and fix it.
// signing (in generateToken)
.signWith(Keys.hmacShaKeyFor(secretkey.getBytes()))
// verifying (in extractAllClaims)
Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretkey)))
.build()
.parseSignedClaims(token);Predict: which exception is thrown when a freshly issued token is parsed?
Show the full walkthrough
Root cause — the key is derived two different ways. Signing uses secretkey.getBytes(), treating the Base64 text as raw bytes. Verifying uses
Decoders.BASE64.decode(secretkey), which turns that text back into the original
binary. Those two byte arrays are different, so the signing key and the verifying key are different keys.
Predicted failure. The signature stored in the token was computed with one key; the parser
recomputes it with another, they don't match, and jjwt throws io.jsonwebtoken.security.SignatureException: JWT signature does not match locally computed signature.
Login still "works" because it never verifies — it only signs — which is why the bug only surfaces on
protected requests.
Step 1 — pick one representation. The secret is stored as Base64, so decode it in both places.
Step 2 — route both paths through one getKey(). Centralising the
key removes any chance of the two sides drifting apart.
private SecretKey getKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretkey);
return Keys.hmacShaKeyFor(keyBytes);
}
// signing
.signWith(getKey())
// verifying
Jwts.parser().verifyWith(getKey()).build().parseSignedClaims(token);Why: HS256 is symmetric — the same secret both signs and verifies. Any asymmetry in how you derive that key from storage breaks verification for every token, even perfectly valid ones.
Sources: Java 301 · Session 8 slide deck. Code marked [+] reconstructs the standard jjwt / Spring Security implementation the deck's code screenshots demonstrate; the verbatim key-generator snippet is from the deck. No separate voice transcript was provided, so scope follows the slides.