JWT Authentication in Spring Boot
Implement stateless JWT authentication in Spring Boot — generating tokens, validating them in a filter, refresh tokens, and securing endpoints.
JWT (JSON Web Token) is the standard approach for stateless authentication in REST APIs. The server issues a signed token after login; the client attaches it to every subsequent request; the server verifies the signature without consulting a session store. This makes the architecture horizontally scalable — any server instance can verify any token independently.
Dependencies
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
Configuration
Keep the JWT secret and expiry times in application.properties so they can vary per environment without recompiling. The secret must be at least 256 bits (32 characters) for HMAC-SHA256 — use a randomly generated value, never a human-readable string.
# application.properties
app.jwt.secret=${JWT_SECRET} # read from environment — never hardcode a secret
app.jwt.expiration=900000 # 15 minutes in milliseconds — short to limit exposure
app.jwt.refresh-expiration=604800000 # 7 days in milliseconds for the refresh token
JWT Service
The JwtService is responsible for generating tokens and extracting claims from them. The signing key is derived from the base64-encoded secret. extractClaim is a generic method that accepts a function, which lets callers extract any claim (subject, expiry, custom fields) with one method.
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secret;
@Value("${app.jwt.expiration}")
private long expiration;
// Decode the base64 secret and create the HMAC signing key
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
}
// Generate a token with no extra claims — just the username and expiry
public String generateToken(UserDetails userDetails) {
return generateToken(Map.of(), userDetails);
}
// Generate a token with additional claims (e.g. roles, token type)
public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
return Jwts.builder()
.claims(extraClaims)
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey())
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
// Validates the token: username matches AND not expired
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
// Generic claim extractor — parses the token, verifies signature, applies the resolver
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
Claims claims = Jwts.parser()
.verifyWith(getSigningKey()) // verifies the signature; throws if tampered
.build()
.parseSignedClaims(token)
.getPayload();
return claimsResolver.apply(claims);
}
}
JWT Authentication Filter
The filter runs on every request. It extracts the token from the Authorization header, validates it, and — if valid — populates the Spring Security context with the user’s identity. Once the context is populated, Spring Security’s authorization rules apply normally. The filter is stateless: it reads no session store and writes no state.
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain)
throws ServletException, IOException {
String authHeader = req.getHeader("Authorization");
// No bearer token — pass through; Spring Security will handle unauthenticated access
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
chain.doFilter(req, res);
return;
}
String token = authHeader.substring(7); // strip "Bearer " prefix
String username;
try {
username = jwtService.extractUsername(token);
} catch (JwtException e) {
// Malformed or tampered token — pass through and let Spring Security return 401
chain.doFilter(req, res);
return;
}
// Authenticate if we have a username and no existing auth in the context
// (avoid re-authenticating for every filter in the chain)
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token, userDetails)) {
// Create an authentication token and set it in the security context
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
chain.doFilter(req, res); // always continue the filter chain
}
}
Wire the Filter into Security Config
Register the JWT filter to run before Spring Security’s built-in UsernamePasswordAuthenticationFilter. This ensures the security context is populated from the JWT before any authorization checks run.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // stateless API — no CSRF needed
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // login/register don't need a token
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
// JWT filter runs before the username/password filter so the context is set first
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
@Bean
public AuthenticationManager authManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
Auth Controller
The auth endpoints are public — they don’t require a token. Login authenticates the credentials and returns both an access token and a refresh token. The refresh endpoint issues a new access token without requiring the user to log in again.
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public AuthResponse register(@RequestBody @Valid RegisterRequest req) {
return authService.register(req);
}
@PostMapping("/login")
public AuthResponse login(@RequestBody @Valid LoginRequest req) {
return authService.login(req);
}
@PostMapping("/refresh")
public AuthResponse refresh(@RequestBody RefreshRequest req) {
return authService.refreshToken(req.refreshToken());
}
}
public record LoginRequest(@NotBlank String email, @NotBlank String password) {}
public record RegisterRequest(@NotBlank String name, @Email String email,
@Size(min = 8) String password) {}
public record AuthResponse(String accessToken, String refreshToken, UserResponse user) {}
Auth Service
AuthService delegates credential verification to Spring Security’s AuthenticationManager. If credentials are wrong, authManager.authenticate() throws AuthenticationException and the login fails — no additional logic needed. On success, both tokens are generated and returned.
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authManager;
public AuthResponse register(RegisterRequest req) {
if (userRepository.existsByEmail(req.email())) {
throw new DuplicateResourceException("Email already registered");
}
User user = userRepository.save(new User(
req.name(), req.email(),
passwordEncoder.encode(req.password()), UserRole.USER));
return generateResponse(user);
}
public AuthResponse login(LoginRequest req) {
// Throws AuthenticationException if credentials are invalid — handled by global handler
authManager.authenticate(
new UsernamePasswordAuthenticationToken(req.email(), req.password()));
User user = userRepository.findByEmail(req.email())
.orElseThrow(() -> new ResourceNotFoundException("User", req.email()));
return generateResponse(user);
}
private AuthResponse generateResponse(User user) {
UserDetails userDetails = org.springframework.security.core.userdetails.User
.withUsername(user.getEmail())
.password(user.getPasswordHash())
.roles(user.getRole().name())
.build();
String accessToken = jwtService.generateToken(userDetails);
// Refresh token carries a "type" claim so it can be distinguished from access tokens
String refreshToken = jwtService.generateToken(Map.of("type", "refresh"), userDetails);
return new AuthResponse(accessToken, refreshToken, UserResponse.from(user));
}
}
Using the API
# Register a new user
curl -X POST /api/auth/register \
-H "Content-Type: application/json" \
-d '{"name":"Alice","email":"alice@example.com","password":"Secret123!"}'
# Login — returns access and refresh tokens
curl -X POST /api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"Secret123!"}'
# {"accessToken":"eyJ...","refreshToken":"eyJ...","user":{...}}
# Call a protected endpoint — attach the access token in the Authorization header
curl /api/users/me \
-H "Authorization: Bearer eyJ..."