Study Notes
Spring Boot by Buildingintegration

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.

45 min read·verified Spring Boot 4.x·reviewed 2026-08-28

After this you should be able to

  • Issue a signed token
  • Verify a token in a request filter
JWTBearer TokenFilter

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.

Chapter 12 · We do — fully guided · Builds on Chapter 11 · Hands-on ~60 min


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.

Learning goal
  • 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.

Learn next: Use issue a signed token as the base, then integrate spring boot skills in one application in TaskFlow Integration Sprint. Interview next: Use issue a signed token as the base, then turn requirements into a design in From requirements to a blueprint.

Continue to TaskFlow Integration Sprint

// the big idea

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:password on 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:

PartHolds
HeaderThe algorithm, e.g. HS256.
Payload (claims)Who + facts: subject (email), role, issued-at, expiry. Readable by anyone — never put secrets here.
SignatureHeader+payload signed with the server's secret. Proves the token wasn't forged or altered.
// a JWT is signed, not encrypted

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:

POST /auth/login→ verify password→ JwtService mints token→ client stores it
GET /tasks + Bearer token→ JwtAuthFilter verifies→ sets who-you-are→ controller runs
add to pom.xml (jjwt 0.12.x)
<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>
application.properties
# 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

Section 3Piece 1 — the JwtService (mint & verify)#

security/JwtService.java
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();
    }
}
what this class is
  • Two methods: 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.
  • 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.

security/JwtAuthFilter.java
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
    }
}
the mental model
  • 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.

security/SecurityConfig.java — updated
@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
    }
}
what changed vs Chapter 11
  • 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#

service/AuthService.java — add login; controller adds /auth/login
// 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:

# 1. register (from Ch 9), then log in to get a token $ curl -X POST localhost:8080/auth/login -H "Content-Type: application/json" \ -d '{"email":"me@x.com","password":"pass123"}' {"token":"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtZUB4LmNvbS%E2%80%A6"} # 2. call a protected endpoint with the token (no password) $ curl localhost:8080/tasks -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ..." [] # 3. no token -> 401 $ curl localhost:8080/tasks {"status":401,...}
// step back and see the whole machine

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#

Exercise 10.1 you do

Design RentVideo Advanced's "max 2 active rentals" rule#

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 RentalService method, 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
Inside the service you can read the logged-in email with 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
Count the user's active rentals via a repository query (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.
// next

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
Store the signing secret in configuration, not source.
Hint 2
Verify before putting authentication in the context.
Hint 3
Keep authorization rules unchanged while changing credential transport.

Before opening the solution: login, call a protected route, corrupt the token, and compare the response.

Reference patch — JWT request flow
Login signs claims; the filter verifies them and establishes the same authenticated principal used by owner rules.

Recall before continuing#

  1. What is signed in a JWT?
  2. Where is bearer authentication established?
  3. Why does the secret stay out of commits?

The next TaskFlow sprint combines these seams under one new requirement and makes the evidence meaningful.

// integration checkpoint

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.

  1. Trace one owner-scoped TaskFlow update from bearer header through controller, service, repository, and response.
  2. Choose a failed update and explain which layer returns the error, which layer rolls back work, and what the client sees.
  3. Name the test, curl check, and log line you would require for a new cross-cutting TaskFlow requirement.