Skip to content
Chapter 39 · Spring Boot

Spring Security Part 6: JWT Authentication ​

JWT is stateless: the server keeps no session, and each request carries a signed token (header, payload, signature; see the previous part). The plan:

  1. User creation: POST /auth/register stores a user with a hashed password (part 2).
  2. Token generation: POST /generate-token with username and password; if they match the database, return a JWT.
  3. Token validation: requests to protected APIs carry Authorization: Bearer <token>; a valid token lets the request through, an invalid one is rejected.
  4. Refresh: access tokens are short-lived (minutes). A long-lived refresh token lets the client get a new access token from POST /refresh-token without the password.

Spring Security has no ready-made JWT login ​

For form login and basic authentication we wrote almost nothing: formLogin(...) or httpBasic(...) switched on complete implementations. For issuing your own JWTs there is none, because every application wants something different: what goes in the payload, which algorithm, whether and how to refresh. So you implement it, and you will find many different implementations online.

(Spring Security does ship a resource server that validates JWTs issued by an external authorization server; that is the OAuth2 setup in the next parts. Here the application issues its own tokens.)

The approach here: extend the security framework itself, rather than putting token logic in controllers.

The hook: how the ProviderManager picks a provider ​

From the architecture:

  1. A security filter takes data from the request (username and password, session, token) and creates an Authentication object, not yet authenticated. Each filter creates its own type: UsernamePasswordAuthenticationFilter creates a UsernamePasswordAuthenticationToken, and so on.
  2. The filter does not know which provider can handle it. It passes the object to the AuthenticationManager (ProviderManager).
  3. The ProviderManager holds a list of providers and asks each in turn: supports(authentication class)? The first that says yes gets authenticate(...):
java
// ProviderManager.authenticate, simplified
for (AuthenticationProvider provider : getProviders()) {
    if (!provider.supports(toTest)) {
        continue;
    }
    result = provider.authenticate(authentication);
    ...
}
  1. The provider returns a fully authenticated object; the filter stores it in the SecurityContextHolder.

So to add JWT, we plug into these extension points:

  • a custom filter, added at the right place in the filter chain
  • which creates a custom Authentication object
  • a custom provider whose supports accepts that object
  • added to the ProviderManager's list

Dependencies ​

xml
<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>
  • jjwt-api: the API you call to build and parse tokens.
  • jjwt-impl: its implementation.
  • jjwt-jackson: JSON processing for the header and payload.

Step 1: user creation ​

Exactly as in part 2: an entity implementing UserDetails, a repository, a service implementing UserDetailsService, and POST /auth/register, which hashes the password with the PasswordEncoder and saves the user (role stored as ROLE_USER).

Step 2: token generation ​

JwtUtil ​

java
@Component
public class JwtUtil {

    // demo only: keep real secrets out of the code (environment variable, vault...)
    private static final String SECRET = "my-demo-secret-key-that-is-at-least-32-bytes-long";
    private final SecretKey key = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));

    public String generateToken(String username, long expiryMinutes, String tokenType) {
        return Jwts.builder()
                .subject(username)
                .claim("type", tokenType)                  // "access" or "refresh"
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + expiryMinutes * 60 * 1000))
                .signWith(key, Jwts.SIG.HS256)
                .compact();
    }

    public String validateAndExtractUsername(String token, String expectedType) {
        try {
            Claims claims = Jwts.parser().verifyWith(key).build()
                    .parseSignedClaims(token)              // checks signature and expiry
                    .getPayload();
            return expectedType.equals(claims.get("type")) ? claims.getSubject() : null;
        } catch (JwtException | IllegalArgumentException e) {
            return null;
        }
    }
}

HS256 is HMAC, a symmetric algorithm: the same secret signs and verifies. The key must be at least 256 bits (32 bytes) for HS256.

The type claim matters: access and refresh tokens are signed with the same key, so without it a 7-day refresh token would also be accepted as an access token. (Tested: without the check, a refresh token in the Authorization header opened /users.)

The generation filter ​

java
public record LoginRequest(String username, String password) {}
java
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final AuthenticationManager authenticationManager;
    private final JwtUtil jwtUtil;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtUtil jwtUtil) {
        this.authenticationManager = authenticationManager;
        this.jwtUtil = jwtUtil;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        if (!request.getServletPath().equals("/generate-token")) {
            filterChain.doFilter(request, response);    // not for us: next filter
            return;
        }

        LoginRequest login = new ObjectMapper().readValue(request.getInputStream(), LoginRequest.class);
        UsernamePasswordAuthenticationToken authToken =
                new UsernamePasswordAuthenticationToken(login.username(), login.password());

        Authentication authResult;
        try {
            authResult = authenticationManager.authenticate(authToken);
        } catch (AuthenticationException e) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
            return;
        }

        String token = jwtUtil.generateToken(authResult.getName(), 15, "access");
        response.setHeader("Authorization", "Bearer " + token);
        // refresh token: see step 4
    }
}
  • OncePerRequestFilter guarantees the filter runs only once per request, even if the request is dispatched internally again.
  • The filter acts only on /generate-token, just as UsernamePasswordAuthenticationFilter acts only on /login.
  • It deliberately creates a UsernamePasswordAuthenticationToken: the DaoAuthenticationProvider supports it and already does everything needed (load the user, check the hashed password). No need to rewrite that.
  • On success it returns the token in the Authorization header and does not call filterChain.doFilter: the response is complete, no controller involved.

Step 3: token validation ​

A custom Authentication object ​

java
public class JwtAuthenticationToken extends AbstractAuthenticationToken {

    private final String token;
    private final String tokenType;

    public JwtAuthenticationToken(String token, String tokenType) {
        super(null);
        this.token = token;
        this.tokenType = tokenType;
        setAuthenticated(false);
    }

    public String getToken() { return token; }
    public String getTokenType() { return tokenType; }

    @Override public Object getCredentials() { return token; }
    @Override public Object getPrincipal() { return null; }
}

A provider that supports it ​

java
public class JwtAuthenticationProvider implements AuthenticationProvider {

    private final JwtUtil jwtUtil;
    private final UserDetailsService userDetailsService;

    public JwtAuthenticationProvider(JwtUtil jwtUtil, UserDetailsService userDetailsService) {
        this.jwtUtil = jwtUtil;
        this.userDetailsService = userDetailsService;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        JwtAuthenticationToken jwtAuth = (JwtAuthenticationToken) authentication;
        String username = jwtUtil.validateAndExtractUsername(jwtAuth.getToken(), jwtAuth.getTokenType());
        if (username == null) {
            throw new BadCredentialsException("Invalid JWT token");
        }
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return JwtAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

It validates the token, loads the user and returns an authenticated object with the user's roles. For simplicity it reuses UsernamePasswordAuthenticationToken (whose three-argument constructor marks it authenticated); you could instead extend JwtAuthenticationToken to hold the user details. (Loading the user from the database on each request is optional; putting the roles in the token as a claim avoids it, at the cost of roles only changing when the token is renewed.)

The validation filter ​

java
public class JwtValidationFilter extends OncePerRequestFilter {

    private final AuthenticationManager authenticationManager;

    public JwtValidationFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String token = extractJwtFromRequest(request);
        if (token != null) {
            try {
                Authentication authResult =
                        authenticationManager.authenticate(new JwtAuthenticationToken(token, "access"));
                SecurityContextHolder.getContext().setAuthentication(authResult);
            } catch (AuthenticationException e) {
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
                return;
            }
        }
        filterChain.doFilter(request, response);    // continue to authorization and the controller
    }

    private String extractJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

This one runs for every request. When a token is valid, the authenticated object goes into the SecurityContextHolder (so the controller can see the user too), and the chain continues.

Step 4: refresh tokens ​

Access tokens are short-lived: a leaked JWT cannot be revoked before it expires (previous part), so the window must be small. But asking for the password every 15 minutes is unacceptable. So along with the access token, /generate-token also issues a refresh token valid for days. In JwtAuthenticationFilter, after setting the access token:

java
String refreshToken = jwtUtil.generateToken(authResult.getName(), 7 * 24 * 60, "refresh");   // 7 days
Cookie refreshCookie = new Cookie("refreshToken", refreshToken);
refreshCookie.setHttpOnly(true);             // JavaScript cannot read it
refreshCookie.setSecure(true);               // sent only over HTTPS
refreshCookie.setPath("/refresh-token");     // sent only to this endpoint
refreshCookie.setMaxAge(7 * 24 * 60 * 60);
response.addCookie(refreshCookie);

You could also return it in the response body and let the client store it. A cookie with these three settings keeps the long-lived token away from page scripts, off plain HTTP, and out of every other request. The access token is also exposed to theft, but it expires in minutes.

java
public class JwtRefreshFilter extends OncePerRequestFilter {

    private final AuthenticationManager authenticationManager;
    private final JwtUtil jwtUtil;

    public JwtRefreshFilter(AuthenticationManager authenticationManager, JwtUtil jwtUtil) {
        this.authenticationManager = authenticationManager;
        this.jwtUtil = jwtUtil;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        if (!request.getServletPath().equals("/refresh-token")) {
            filterChain.doFilter(request, response);
            return;
        }

        String refreshToken = extractJwtFromCookie(request);
        if (refreshToken == null) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "no refresh token");
            return;
        }

        Authentication authResult;
        try {
            authResult = authenticationManager.authenticate(new JwtAuthenticationToken(refreshToken, "refresh"));
        } catch (AuthenticationException e) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
            return;
        }

        String newAccessToken = jwtUtil.generateToken(authResult.getName(), 15, "access");
        response.setHeader("Authorization", "Bearer " + newAccessToken);
    }

    private String extractJwtFromCookie(HttpServletRequest request) {
        if (request.getCookies() == null) return null;
        for (Cookie cookie : request.getCookies()) {
            if (cookie.getName().equals("refreshToken")) return cookie.getValue();
        }
        return null;
    }
}

It reuses JwtAuthenticationToken and JwtAuthenticationProvider; only the expected token type differs.

The security configuration ​

java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(UserDetailsService userDetailsService,
                                                       PasswordEncoder passwordEncoder, JwtUtil jwtUtil) {
        DaoAuthenticationProvider daoProvider = new DaoAuthenticationProvider();
        daoProvider.setUserDetailsService(userDetailsService);
        daoProvider.setPasswordEncoder(passwordEncoder);

        return new ProviderManager(List.of(daoProvider, new JwtAuthenticationProvider(jwtUtil, userDetailsService)));
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationManager authenticationManager,
                                                   JwtUtil jwtUtil) throws Exception {
        JwtAuthenticationFilter authFilter = new JwtAuthenticationFilter(authenticationManager, jwtUtil);
        JwtValidationFilter validationFilter = new JwtValidationFilter(authenticationManager);
        JwtRefreshFilter refreshFilter = new JwtRefreshFilter(authenticationManager, jwtUtil);

        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/register", "/error").permitAll()
                .requestMatchers("/users").hasRole("USER")
                .anyRequest().authenticated())
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable())
            .addFilterBefore(authFilter, UsernamePasswordAuthenticationFilter.class)
            .addFilterAfter(validationFilter, JwtAuthenticationFilter.class)
            .addFilterAfter(refreshFilter, JwtValidationFilter.class);

        return http.build();
    }
}
  • ProviderManager with your own list. When you build it yourself, it contains exactly the providers you give it, so both are listed: DaoAuthenticationProvider (for /generate-token) and JwtAuthenticationProvider (for tokens). DaoAuthenticationProvider needs the UserDetailsService and PasswordEncoder. With formLogin() or httpBasic(), Spring wired this for you; here you do it.
  • The filters are not beans (no @Component), so you create them and place them in the chain yourself: the generation filter before UsernamePasswordAuthenticationFilter, the validation filter after it, the refresh filter after that. Spring has no way to guess where a new filter belongs.
  • STATELESS and CSRF disabled: no session, and the token travels in a header the client sets itself.
  • /error is permitted so that error responses from sendError keep their status. Otherwise the error page itself requires authentication and every failure shows up as 403.

Trying it ​

http
POST /auth/register   {"username": "alice", "password": "123", "role": "ROLE_USER"}
→ 200 User registered successfully!

POST /generate-token  {"username": "alice", "password": "123"}
→ 200
  Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJTSiIsInR5cGUiOiJhY2Nlc3MiLC...
  Set-Cookie: refreshToken=...; Max-Age=604800; Path=/refresh-token; Secure; HttpOnly

The payload decodes to {"sub":"alice","type":"access","iat":...,"exp":...}: 15 minutes between iat and exp.

RequestResult
POST /generate-token with a wrong password401 Bad credentials
GET /users with Authorization: Bearer <access token>200
GET /users without a token403 Access Denied
GET /users with a tampered token401 Invalid JWT token
GET /users with the refresh token as bearer401 Invalid JWT token
POST /refresh-token (cookie sent automatically)200, new access token in Authorization
GET /users with a token for a user with only ROLE_ADMIN403 Forbidden

Authorization works exactly as with form and basic login: .requestMatchers("/users").hasRole("USER"), checked by the AuthorizationFilter from the roles in the security context, before the request reaches the controller.

Next: With JWT, your application issues its own tokens. Often users would rather log in with an account they already have, such as Google or GitHub. Part 7 explains OAuth 2.0, the standard behind "Sign in with ...".

Check yourself ​

  1. Why does Spring Security not provide a ready-made "issue your own JWT" login?
  2. How does the ProviderManager choose a provider for an Authentication object?
  3. Which four extension points does the JWT implementation use?
  4. Why does the generation filter create a UsernamePasswordAuthenticationToken?
  5. Why does the generation filter not call filterChain.doFilter, while the validation filter does?
  6. What does OncePerRequestFilter guarantee?
  7. Why must both providers be passed to the ProviderManager?
  8. How are the custom filters placed in the chain, and why must you do it yourself?
  9. Why are access tokens short-lived, and what is the refresh token for?
  10. Why store the refresh token in an HttpOnly, Secure cookie with a path?
  11. What goes wrong if access and refresh tokens are indistinguishable, and how is that fixed here?
  12. Why is /error permitted?
Answers
  1. Every application needs something different (payload, algorithm, refresh strategy), so it leaves the implementation to you.
  2. It asks each provider in its list whether it supports the object's class and delegates to the first that does.
  3. A custom filter in the chain, a custom Authentication object, a custom provider supporting it, and that provider in the ProviderManager's list.
  4. DaoAuthenticationProvider supports it and already loads the user and checks the hashed password.
  5. Generation completes the response itself (the token), so no controller is needed; validation only authenticates, and the request must continue to authorization and the controller.
  6. The filter runs only once per request, even on internal re-dispatches.
  7. A ProviderManager you build holds only the providers you list: DAO for /generate-token, JWT for tokens.
  8. With addFilterBefore/addFilterAfter, relative to known filters; they are not beans and Spring cannot know their place.
  9. A leaked JWT cannot be revoked before expiry, so the window is kept short; the refresh token gets a new access token without the password.
  10. HttpOnly: page JavaScript cannot read it; Secure: only over HTTPS; path: sent only to /refresh-token.
  11. A long-lived refresh token could be used as an access token; a type claim is checked for each use.
  12. Otherwise the error dispatch after sendError needs authentication and the client sees 403 instead of 401.