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.
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.
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.
Use this chapter’s TaskFlow work to make one small, observable change. Run the stated acceptance checks before recording practice.
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.
email:password on every requestA 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.
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 hour
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();
}
}
generate (login mints a token) and parse (verify + read claims).
parse throws automatically if the signature is wrong or the token expired — you don't check by
hand.@Value, exactly like the alert-limit in Chapter 8. In
production it's an environment variable, never hard-coded or committed.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
}
}
hasRole("ADMIN") rules from Chapter 11 keep working — unchanged."ROLE_" + role — same prefix rule as Chapter 11. Keep it consistent.SecurityConfig for stateless + the filterThree 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.// 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.
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.
RentalService method, not the controller or the security layer.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.countByUserEmailAndReturnedFalse(email)); if >= 2, throw a
RentalLimitExceededException annotated @ResponseStatus(CONFLICT) (409). Security
said who; the service decides whether.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.
JWT on one page
/auth/login → verify password once, return the tokenChapter 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.
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.
Create a login response, verify its token in the request filter, and repeat the owner-scoped curl checks with a bearer header.
Likely files: security/JwtService.java, security/JwtAuthenticationFilter.java, security/SecurityConfig.java. Suggested commit: feat: add taskflow jwt authentication.
Before opening the solution: login, call a protected route, corrupt the token, and compare the response.
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.