Spring Security · Authentication

JWT Authentication with Spring Security

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.

Java 301 · Session 8  ·  Updated August 2026  ·  Read ~18 min

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.

Learning goal

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.

  1. Centralize key loading and token verification in one service.
  2. Keep the filter focused on extraction, verification, and context creation.
  3. 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

  1. What does a JWT signature prove?
  2. Why is decoding insufficient?
  3. Which claim limits token lifetime?
  4. Where should bearer verification run?
  5. 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.

  1. Explain where request validation ends and a business rule begins for a TaskFlow update.
  2. Describe one operation that needs a service transaction and the partial state it prevents.
  3. Walk through why a decoded JWT cannot authorize a request until signature and expiry checks pass.
// the big idea

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.

Section 1

Notebook Summary

Hand-copyable. Fully derivable from the detailed notes below.

§ Basic Auth — why it fails

§ JWT — what it is

§ Signature & trust

§ Auth flow

§ Spring wiring

§ Role-based authz (RBAC)

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

Section 2

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:

the header Basic Auth sends every request
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.

Analogy · recall

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.

Client Basic user:pass Base64(user:pass) — plain Server Eavesdropper copies header, decodes
fig 2.1 — one intercepted header hands over the raw credentials

The deeper problem is not just interception. Basic Auth has no memory and no lifecycle:

weak protection

Base64 is trivially decoded; over plain HTTP the password is effectively in the clear.

no expiry

Once captured, credentials are valid forever — there is no built-in "this expires at 6pm".

no logout / session

Nothing to invalidate. Killing access often means a full password reset for everyone.

brute-force open

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.

⚠ pitfall

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

stateless

The server stores nothing about your session. It doesn't need a session table — everything it needs is in the token.

self-contained

The token itself carries the identity and claims (who, issued-when, expires-when), so no extra database round-trip.

compact

It's a short URL-safe string, so it fits in an HTTP header, a query parameter, or a POST body.

signed

A cryptographic signature (HMAC or RSA) lets the server detect any tampering. Change one byte and the signature no longer matches.

Analogy · recall

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.

HEADER alg, typ . PAYLOAD claims: sub, iat, exp . SIGNATURE HMAC(h.p, secret) readable readable verifiable, not forgeable
fig 2.3 — three parts, two dots; only the signature needs the secret

Take a real token from this lesson and decode it. The header names the algorithm:

header — decoded
{ "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:

payload — decoded
{
  "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.

⚠ pitfall

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.

💡 tip

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.

POST /login (creds) → server verifies → signs JWT → client stores → Bearer <jwt> → filter validates → resource
Client Server + Filter — once — POST /login {user, pass} verify + sign token 200 OK → JWT — each request — GET /... Authorization: Bearer jwt check signature + exp, set SecurityContext 200 resource · or 401/403
fig 2.4 — one login, then a signature check on every subsequent call

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.

stateful sessions

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
stateless JWT

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
⚠ pitfall

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:

pom.xml — jjwt dependencies [+]
<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:

JWTService — generating a key on the fly
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);
    }
}
💡 tip

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:

JWTService — generateToken + getKey [+]
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);
}
⚠ pitfall

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.

AuthController — POST /login [+]
@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.

JWTService — extraction + validation [+]
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.

Analogy · recall

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.

JwtFilter extends OncePerRequestFilter [+]
@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);
    }
}
⚠ pitfall

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.

SecurityConfig — the filter chain [+]
@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();
    }
}
Request JwtFilter verify → context authz rules role match? Controller no role → 403 · no token → 401
fig 2.8 — the filter authenticates; the rules authorize; only then the controller

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
💡 tip

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.

💡 tip

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.

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

encoding ≠ encryption header.payload.signature stateless Bearer token exp claim HS256 secret OncePerRequestFilter addFilterBefore hasRole("ADMIN") 401 vs 403

Section 3

Microexercises

Reading proves you recognise it; these prove you can use it. Attempt each before opening the walkthrough.

3.1Build it — mint an HS256 token

// your task

Write a generateToken(String username) method and its getKey() helper for the JWTService. The stored secret is a Base64 string field named secretkey.

Requirements:

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.

worked solution
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

// your task

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.

buggy JWTService — signing vs verifying
// 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.

the fix — one symmetric key path
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.

decode, don't getBytes() one getKey() path exp = now + 24h signWith → compact SignatureException = key mismatch sign ≠ verify bug hides at login

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.