Appearance
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:
- User creation:
POST /auth/registerstores a user with a hashed password (part 2). - Token generation:
POST /generate-tokenwith username and password; if they match the database, return a JWT. - Token validation: requests to protected APIs carry
Authorization: Bearer <token>; a valid token lets the request through, an invalid one is rejected. - Refresh: access tokens are short-lived (minutes). A long-lived refresh token lets the client get a new access token from
POST /refresh-tokenwithout 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:
- A security filter takes data from the request (username and password, session, token) and creates an
Authenticationobject, not yet authenticated. Each filter creates its own type:UsernamePasswordAuthenticationFiltercreates aUsernamePasswordAuthenticationToken, and so on. - The filter does not know which provider can handle it. It passes the object to the
AuthenticationManager(ProviderManager). - The
ProviderManagerholds a list of providers and asks each in turn:supports(authentication class)? The first that says yes getsauthenticate(...):
java
// ProviderManager.authenticate, simplified
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
result = provider.authenticate(authentication);
...
}- 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
Authenticationobject - a custom provider whose
supportsaccepts 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
}
}OncePerRequestFilterguarantees the filter runs only once per request, even if the request is dispatched internally again.- The filter acts only on
/generate-token, just asUsernamePasswordAuthenticationFilteracts only on/login. - It deliberately creates a
UsernamePasswordAuthenticationToken: theDaoAuthenticationProvidersupports 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
Authorizationheader and does not callfilterChain.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();
}
}ProviderManagerwith your own list. When you build it yourself, it contains exactly the providers you give it, so both are listed:DaoAuthenticationProvider(for/generate-token) andJwtAuthenticationProvider(for tokens).DaoAuthenticationProviderneeds theUserDetailsServiceandPasswordEncoder. WithformLogin()orhttpBasic(), 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 beforeUsernamePasswordAuthenticationFilter, the validation filter after it, the refresh filter after that. Spring has no way to guess where a new filter belongs. STATELESSand CSRF disabled: no session, and the token travels in a header the client sets itself./erroris permitted so that error responses fromsendErrorkeep 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; HttpOnlyThe payload decodes to {"sub":"alice","type":"access","iat":...,"exp":...}: 15 minutes between iat and exp.
| Request | Result |
|---|---|
POST /generate-token with a wrong password | 401 Bad credentials |
GET /users with Authorization: Bearer <access token> | 200 |
GET /users without a token | 403 Access Denied |
GET /users with a tampered token | 401 Invalid JWT token |
GET /users with the refresh token as bearer | 401 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_ADMIN | 403 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
- Why does Spring Security not provide a ready-made "issue your own JWT" login?
- How does the
ProviderManagerchoose a provider for anAuthenticationobject? - Which four extension points does the JWT implementation use?
- Why does the generation filter create a
UsernamePasswordAuthenticationToken? - Why does the generation filter not call
filterChain.doFilter, while the validation filter does? - What does
OncePerRequestFilterguarantee? - Why must both providers be passed to the
ProviderManager? - How are the custom filters placed in the chain, and why must you do it yourself?
- Why are access tokens short-lived, and what is the refresh token for?
- Why store the refresh token in an
HttpOnly,Securecookie with a path? - What goes wrong if access and refresh tokens are indistinguishable, and how is that fixed here?
- Why is
/errorpermitted?
Answers
- Every application needs something different (payload, algorithm, refresh strategy), so it leaves the implementation to you.
- It asks each provider in its list whether it
supportsthe object's class and delegates to the first that does. - A custom filter in the chain, a custom
Authenticationobject, a custom provider supporting it, and that provider in theProviderManager's list. DaoAuthenticationProvidersupports it and already loads the user and checks the hashed password.- 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.
- The filter runs only once per request, even on internal re-dispatches.
- A
ProviderManageryou build holds only the providers you list: DAO for/generate-token, JWT for tokens. - With
addFilterBefore/addFilterAfter, relative to known filters; they are not beans and Spring cannot know their place. - 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.
HttpOnly: page JavaScript cannot read it;Secure: only over HTTPS; path: sent only to/refresh-token.- A long-lived refresh token could be used as an access token; a
typeclaim is checked for each use. - Otherwise the error dispatch after
sendErrorneeds authentication and the client sees 403 instead of 401.