Tutorial 11: API Security with JWT
Token generation, the authentication filter, stateless session policy, and Spring Security configuration.
← Back to TutorialsThe restaurant system is internal: only authenticated staff should access it. This tutorial walks through the JWT security layer: how tokens are generated and validated by JwtService, how each request is intercepted and authenticated by JwtAuthenticationFilter, and how SecurityConfig wires it all together with a stateless session policy.
1. JwtService: token generation and validation
@Service
public class JwtService {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.expiration}")
private long jwtExpiration; // milliseconds, e.g. 86400000 = 24h
// Build a signed JWT containing the user's email and role
public String generateToken(UserDetails userDetails) {
Map<String, Object> extraClaims = new HashMap<>();
if (userDetails instanceof User user) {
extraClaims.put("role", user.getRole().name()); // embed "ROLE_ADMIN" or "ROLE_USER"
}
return generateToken(extraClaims, userDetails);
}
public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
return Jwts.builder()
.setClaims(extraClaims)
.setSubject(userDetails.getUsername()) // email = subject
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + jwtExpiration))
.signWith(getSignInKey(), SignatureAlgorithm.HS256)
.compact();
}
private Key getSignInKey() {
return Keys.hmacShaKeyFor(secretKey.getBytes());
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
}
- @Value injection:
jwt.secretandjwt.expirationare read fromapplication.yml: never hardcoded. The secret stays out of source control. - Extra claims: the role is embedded in the token payload so the front end can read it without an extra API call. The role is also re-validated server-side on every request.
- HS256: symmetric signing algorithm: the same secret is used to sign and verify. For a multi-service architecture, RS256 (asymmetric) is preferred.
- extractClaim is a generic helper: it parses all claims once and applies a
Function<Claims, T>to extract whichever field is needed. isTokenValidchecks two things: the username in the token matches the user we loaded from DB, and the token hasn't expired.
2. JwtAuthenticationFilter: intercepting every request
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final CustomUserDetailsService customUserDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// 1. Skip JWT check for auth endpoints
if (request.getServletPath().startsWith("/api/auth")) {
filterChain.doFilter(request, response);
return;
}
// 2. Extract the Authorization header
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// 3. Extract token and username
final String jwtToken = authHeader.substring(7); // strip "Bearer "
final String userEmail = jwtService.extractUsername(jwtToken);
// 4. If not already authenticated, load and validate
if (userEmail != null &&
SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails =
customUserDetailsService.loadUserByUsername(userEmail);
if (jwtService.isTokenValid(jwtToken, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
- OncePerRequestFilter: guarantees the filter runs exactly once per HTTP request, even in forward/include scenarios.
- Early return for /api/auth: login and register must be reachable without a token: skipping the filter for these paths avoids a chicken-and-egg problem.
- authHeader.substring(7): strips the
"Bearer "prefix (7 characters) to get the raw token string. - Authentication == null check: prevents re-processing a request that's already been authenticated by an earlier filter in the chain.
- SecurityContextHolder.getContext().setAuthentication(authToken): this single line tells Spring Security "this request is authenticated as this user." Every downstream security check reads from this context.
- No credentials are stored in the auth token (second argument is
null): the password is not needed after authentication.
3. SecurityConfig: wiring it all together
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomUserDetailsService customUserDetailsService;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager() {
return new ProviderManager(daoAuthenticationProvider());
}
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(customUserDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(withDefaults())
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/**",
"/swagger-ui/**",
"/v3/api-docs/**",
"/orders/*",
"/sessions/*"
).permitAll()
.anyRequest().authenticated()
)
.sessionManagement(sess ->
sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authenticationProvider(daoAuthenticationProvider())
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
- SessionCreationPolicy.STATELESS: Spring Security will never create an HTTP session. Every request must carry its own JWT: this is the defining trait of a REST API.
- csrf disabled: CSRF attacks require a session cookie to hijack. Since this API is stateless and uses Bearer tokens (not cookies), CSRF protection is unnecessary.
- addFilterBefore: inserts the JWT filter before Spring's default
UsernamePasswordAuthenticationFilter, so JWT authentication runs first on every request. - permitAll paths: auth endpoints, Swagger UI, and the orders/sessions paths are public. Everything else requires a valid JWT.
- BCryptPasswordEncoder: passwords are hashed with bcrypt before storage. The work factor makes brute-force attacks expensive even if the database is compromised.
- DaoAuthenticationProvider: the bridge between Spring Security's auth mechanism and your
UserDetailsService(which loads users from the DB).
4. The full request flow
// 1. Client sends:
// POST /api/auth/login { "email": "...", "password": "..." }
// 2. AuthService validates credentials and returns:
// { "token": "eyJhbGciOiJIUzI1NiJ9..." }
// 3. Client stores the token and sends on every subsequent request:
// GET /api/menu-items
// Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
// 4. JwtAuthenticationFilter intercepts the request:
// → extracts "Bearer " → raw token
// → extracts email from claims
// → loads UserDetails from DB
// → validates token signature + expiry
// → sets Authentication in SecurityContext
// 5. Spring Security allows the request through to the controller
Key Takeaways
- Never hardcode JWT secrets: inject them via
@Valuefrom environment-specific config files that are excluded from source control. SessionCreationPolicy.STATELESSis non-negotiable for a JWT REST API: it prevents Spring from creating server-side sessions that contradict the stateless design.- The filter sets authentication in the
SecurityContext: this single object is what all downstream Spring Security checks read from. - Skip the filter for auth endpoints explicitly (
/api/auth/**), otherwise login and register are unreachable without a token. - BCrypt is the right password hasher: never store plain text or use MD5/SHA-1 for passwords.