Stateless auth with JWT
Replace Basic Auth with a learning-focused JWT flow: login issues a signed token, a request filter verifies it, and existing role rules keep working.
Assumes you know
After this you should be able to
- Issue a signed token
- Verify a token in a request filter
Spring Boot by Building · Chapter 12
Basic Auth resends your password on every request. We swap it for a token: log in
once, receive a signed JWT, and carry it in an Authorization: Bearer header thereafter. This is the
steepest chapter in the course — so we build it one clearly-named piece at a time.
Choose your route#
Study the same Stateless auth with JWT explanation with a different purpose: build and verify it in Learn deeply, or retrieve and defend it in Prepare for interviews.
- Issue a signed token
- Verify a token in a request filter
Shared core#
The chapter below remains the canonical explanation for both goals. Keep its model, examples, and trade-offs shared; only the deliberate practice changes with the route.
Learn deeply: build evidence#
Use this chapter’s TaskFlow work to make one small, observable change. Run the stated acceptance checks before recording practice.
Prepare for interviews: explain the trade-off#
Without notes, explain the boundary this chapter protects, one concrete implementation decision, and the cost of a tempting shortcut. Then answer one follow-up using the chapter’s own example.
A JWT is a signed, self-contained ticket. At login the server checks your password once and hands you a token that says "this is me@x.com, role ADMIN," signed with a secret only the server knows. On every later request you present the token; the server verifies the signature and trusts the contents — no database lookup, no session stored on the server. That's what "stateless" means, and it's why JWT scales. In this workshop we build the mechanism by hand for learning; in production Spring Security's OAuth2 Resource Server support is usually the cleaner default.
Section 1Basic Auth vs JWT, and what a token is#
Basic Auth (Ch 9)#
- Sends
email:passwordon every request - Server verifies against the DB each time
- Simple; fine for learning & internal tools
JWT#
- Password sent once, at login
- Server returns a signed token; client reuses it
- Each request verified by signature — no DB hit, no server session
A JWT is three base64 parts joined by dots — header.payload.signature:
| Part | Holds |
|---|---|
| Header | The algorithm, e.g. HS256. |
| Payload (claims) | Who + facts: subject (email), role, issued-at, expiry. Readable by anyone — never put secrets here. |
| Signature | Header+payload signed with the server's secret. Proves the token wasn't forged or altered. |
Anyone can base64-decode the payload and read the claims — so never put a password or secret in a JWT. The signature guarantees integrity (nobody tampered with it), not confidentiality. What keeps it safe is that only the server knows the signing secret, so only the server can mint valid tokens.
Section 2The plan, and the dependency#
Three new pieces sit on top of Chapter 11's users/roles. Keep this flow in mind as you build:
<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># a long, secret string (>= 32 chars for HS256). In real apps: an env var, never committed.
app.jwt.secret=change-me-to-a-very-long-random-secret-at-least-32-bytes
app.jwt.expiration-ms=3600000 # token valid 1 hourSection 3Piece 1 — the JwtService (mint & verify)#
package com.example.taskflow.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.util.Date;
@Service
public class JwtService {
private final SecretKey key;
private final long expirationMs;
public JwtService(@Value("${app.jwt.secret}") String secret, @Value("${app.jwt.expiration-ms}") long expirationMs) {
this.key = Keys.hmacShaKeyFor(secret.getBytes()); // build the signing key
this.expirationMs = expirationMs;
}
// MINT: called at login
public String generate(String email, String role) {
Date now = new Date();
return Jwts.builder().subject(email) // who
.claim("role", role) // their role
.issuedAt(now).expiration(new Date(now.getTime() + expirationMs)).signWith(key) // sign with the secret
.compact();
}
// VERIFY: called on every request; throws if invalid/expired
public Claims parse(String token) {
return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload();
}
}- Two methods:
generate(login mints a token) andparse(verify + read claims).parsethrows automatically if the signature is wrong or the token expired — you don't check by hand. - The secret comes from config via
@Value, exactly like the alert-limit in Chapter 8. In production it's an environment variable, never hard-coded or committed.
Section 4Piece 2 — the filter that runs on every request#
This is the JWT equivalent of Basic Auth's built-in check. A OncePerRequestFilter reads the
Authorization: Bearer ... header, verifies the token, and tells Spring Security who the caller is by
placing an Authentication into the SecurityContext.
package com.example.taskflow.security;
import io.jsonwebtoken.Claims;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwt;
public JwtAuthFilter(JwtService jwt) { this.jwt = jwt; }
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
try {
Claims claims = jwt.parse(header.substring(7)); // drop "Bearer "
String email = claims.getSubject();
String role = claims.get("role", String.class);
var auth = new UsernamePasswordAuthenticationToken(email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role))); // grant the role
SecurityContextHolder.getContext().setAuthentication(auth); // "logged in"
} catch (Exception e) {
SecurityContextHolder.clearContext(); // bad/expired token -> stay anonymous
}
}
chain.doFilter(req, res); // continue down the chain
}
}- Valid token → the filter sets the caller's identity + role for this request, so the same
hasRole("ADMIN")rules from Chapter 11 keep working — unchanged. - No/invalid token → context stays empty; protected endpoints then reject with 401/403. The filter never throws to the client; it just declines to authenticate.
- Note
"ROLE_" + role— same prefix rule as Chapter 11. Keep it consistent.
Section 5Piece 3 — rewire SecurityConfig for stateless + the filter#
Three changes to Chapter 11's config: make sessions stateless, register the JWT filter before the username/password
filter, and expose an AuthenticationManager so the login endpoint can verify passwords.
@Configuration
public class SecurityConfig {
private final JwtAuthFilter jwtFilter;
public SecurityConfig(JwtAuthFilter jwtFilter) { this.jwtFilter = jwtFilter; }
@Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
@Bean
public AuthenticationManager authManager(AuthenticationConfiguration cfg) throws Exception {
return cfg.getAuthenticationManager(); // used by /auth/login
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable).sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).authorizeHttpRequests(auth -> auth.requestMatchers("/auth/**").permitAll() // register + login public
.requestMatchers(HttpMethod.DELETE, "/tasks/**").hasRole("ADMIN").requestMatchers("/tasks/**").hasAnyRole("USER", "ADMIN").anyRequest().authenticated()) // run our JWT filter before Spring's username/password filter
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); // note: no httpBasic() now — tokens replace it
}
}STATELESS→ Spring stores no session; identity comes only from the token each request.addFilterBefore(jwtFilter, ...)→ your filter runs first and populates the context from the token.authManager(...)bean → the login endpoint uses it to check email+password once. The authorization rules are otherwise identical — the seam held again.
Section 6The login endpoint — issue the token#
// AuthService (now also holds AuthenticationManager + JwtService)
public String login(String email, String rawPassword) {
authManager.authenticate( // verifies password via UserDetailsService + BCrypt
new UsernamePasswordAuthenticationToken(email, rawPassword));
AppUser u = users.findByEmail(email).orElseThrow();
return jwt.generate(u.getEmail(), u.getRole().name()); // mint the token
}
// AuthController
public record LoginRequest(String email, String password) { }
@PostMapping("/login")
public Map<String,String> login(@RequestBody LoginRequest req) {
return Map.of("token", auth.login(req.email(), req.password()));
}The whole flow, end to end:
Login checks the password once and returns a signed ticket. Every later request carries the ticket; your filter verifies it and re-uses the exact same role rules from Chapter 11. You added three small classes and rewired one config — and turned a stateful, password-every-time API into a stateless, token-based one. That is precisely what RentVideo Advanced asks for.
Section 7Exercise — the rental rule belongs in the service#
Goal. Decide where the "a user may have at most 2 active rentals" rule lives and sketch the method — using who-you-are from the JWT.
- The rule lives in a
RentalServicemethod, not the controller or the security layer. - The current user's identity comes from the security context (set by your JWT filter), not a request param.
- Renting a 3rd video throws a clear exception → a 4xx, not a 500.
Hint 1 — getting the current user
SecurityContextHolder.getContext().getAuthentication().getName() (that's the subject your filter
set), or inject it in the controller via a Principal parameter and pass it down.Hint 2 — the rule
countByUserEmailAndReturnedFalse(email)); if >= 2, throw a
RentalLimitExceededException annotated @ResponseStatus(CONFLICT) (409). Security
said who; the service decides whether.Show the shape
public Rental rent(String userEmail, Long videoId) {
if (rentals.countByUserEmailAndReturnedFalse(userEmail) >= 2) throw new RentalLimitExceededException(); // @ResponseStatus(HttpStatus.CONFLICT)
Video v = videos.findById(videoId).orElseThrow(() -> new VideoNotFoundException(videoId));
return rentals.save(new Rental(userEmail, v, /*returned*/ false, LocalDateTime.now()));
}Clean separation: JWT proved identity, the service enforced the business rule, the repository counted. Each concern in its home — the whole course in one method.
Notebook Summary#
JWT on one page
Concept#
- JWT = signed ticket: header.payload.signature; log in once, carry the token
- Stateless: verified by signature each request, no server session, no DB hit
- Signed, not encrypted → readable; never put secrets in the payload
The three pieces#
- JwtService → generate + parse (verify) with a secret key
- JwtAuthFilter (OncePerRequestFilter) → read Bearer header, set the SecurityContext
- SecurityConfig → STATELESS, addFilterBefore(jwtFilter), AuthenticationManager bean
/auth/login→ verify password once, return the token
Box these#
- ⭐Same role rules as Ch 9 still apply — the filter just supplies identity differently.
- ⚠Secret ≥ 32 bytes for HS256, from config/env — never committed.
- ⚠Token is public-readable; put no secrets in claims.
- 💡Identity (who) is auth's job; the rule (whether) is the service's job. Keep them apart.
Chapter 13 — TaskFlow integration sprint, then Chapter 14 — Build from scratch, where the Grocery, RentVideo, and RentVideo Advanced specs become full capstones using everything from Chapters 1–10.
TaskFlow checkpoint#
Current state: role checks work with Basic Auth. Outcome: a token-issued and token-verified API. Prerequisite: the filter chain. Java bridge: a request filter can establish an authenticated principal. Estimate: 45 minutes.
Mission: issue and verify a bearer token#
Create a login response, verify its token in the request filter, and repeat the owner-scoped curl checks with a bearer header.
- Login produces a signed token without logging the secret.
- A valid bearer request reaches the protected endpoint.
- A missing or bad token is rejected.
Likely files: security/JwtService.java, security/JwtAuthenticationFilter.java, security/SecurityConfig.java. Suggested commit: feat: add taskflow jwt authentication.
Hint 1
Hint 2
Hint 3
Before opening the solution: login, call a protected route, corrupt the token, and compare the response.
Reference patch — JWT request flow
Recall before continuing#
- What is signed in a JWT?
- Where is bearer authentication established?
- Why does the secret stay out of commits?
The next TaskFlow sprint combines these seams under one new requirement and makes the evidence meaningful.
Before the capstone sprint, explain the connected TaskFlow request path: a bearer token identifies the caller, the service applies the rule, persistence commits the change, tests protect it, and logs make the decision diagnosable.
- Trace one owner-scoped TaskFlow update from bearer header through controller, service, repository, and response.
- Choose a failed update and explain which layer returns the error, which layer rolls back work, and what the client sees.
- Name the test, curl check, and log line you would require for a new cross-cutting TaskFlow requirement.