JWT authentication, role-based access, and best practices with Spring Security 6
August 5, 2025
12 min read
Spring Security 6 is the standard way to secure Spring Boot applications. In this guide, we'll build a complete JWT-based authentication system with role-based access control (RBAC) — the same pattern used in production APIs at companies worldwide.
Before we start, make sure you have a basic Spring Boot 3 project. You'll need these dependencies in your pom.xml:
xml<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> </dependencies>
JWT (JSON Web Token) authentication follows this flow:
/auth/loginAuthorization: Bearer <token>JWTs are stateless — the server doesn't store sessions. All user info is embedded in the token itself (signed but not encrypted). Never store sensitive data like passwords in a JWT payload.
This class handles token generation and validation:
java@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; private static final long EXPIRATION_MS = 86_400_000; // 24 hours public String generateToken(UserDetails userDetails) { Map<String, Object> claims = new HashMap<>(); claims.put("roles", userDetails.getAuthorities() .stream().map(GrantedAuthority::getAuthority).toList()); return Jwts.builder() .setClaims(claims) .setSubject(userDetails.getUsername()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_MS)) .signWith(Keys.hmacShaKeyFor(secret.getBytes()), SignatureAlgorithm.HS256) .compact(); } public String extractUsername(String token) { return parseClaims(token).getSubject(); } public boolean isTokenValid(String token, UserDetails userDetails) { return extractUsername(token).equals(userDetails.getUsername()) && !parseClaims(token).getExpiration().before(new Date()); } private Claims parseClaims(String token) { return Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes())) .build() .parseClaimsJws(token) .getBody(); } }
The filter intercepts every request, extracts the token from the header, and sets the authentication in the security context:
java@Component @RequiredArgsConstructor public class JwtAuthFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { chain.doFilter(request, response); return; } String token = authHeader.substring(7); String username = jwtUtil.extractUsername(token); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); if (jwtUtil.isTokenValid(token, userDetails)) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); } } chain.doFilter(request, response); } }
Spring Security 6 uses a functional, lambda-based DSL instead of extending WebSecurityConfigurerAdapter (removed in Spring Security 6):
java@Configuration @EnableWebSecurity @EnableMethodSecurity @RequiredArgsConstructor public class SecurityConfig { private final JwtAuthFilter jwtAuthFilter; private final UserDetailsService userDetailsService; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/auth/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/**").hasAnyRole("USER", "ADMIN") .anyRequest().authenticated() ) .authenticationProvider(authenticationProvider()) .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 authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
@EnableMethodSecurity activates @PreAuthorize and @PostAuthorize annotations on individual methods — great for fine-grained access control beyond URL patterns.
java@RestController @RequestMapping("/auth") @RequiredArgsConstructor public class AuthController { private final AuthenticationManager authManager; private final UserDetailsService userDetailsService; private final JwtUtil jwtUtil; private final UserService userService; @PostMapping("/login") public ResponseEntity<Map<String, String>> login(@RequestBody LoginRequest request) { authManager.authenticate( new UsernamePasswordAuthenticationToken(request.username(), request.password()) ); UserDetails userDetails = userDetailsService.loadUserByUsername(request.username()); String token = jwtUtil.generateToken(userDetails); return ResponseEntity.ok(Map.of("token", token)); } @PostMapping("/register") public ResponseEntity<String> register(@RequestBody RegisterRequest request) { userService.register(request); return ResponseEntity.status(HttpStatus.CREATED).body("User registered successfully"); } } // DTOs public record LoginRequest(String username, String password) {} public record RegisterRequest(String username, String password, String role) {}
With @EnableMethodSecurity active, you can restrict individual endpoints by role:
java@RestController @RequestMapping("/api/products") @RequiredArgsConstructor public class ProductController { private final ProductService productService; @GetMapping public List<Product> getAll() { return productService.getAll(); // any authenticated user } @PostMapping @PreAuthorize("hasRole('ADMIN')") public Product create(@RequestBody Product product) { return productService.create(product); // ADMIN only } @DeleteMapping("/{id}") @PreAuthorize("hasRole('ADMIN')") public void delete(@PathVariable Long id) { productService.delete(id); // ADMIN only } }
properties# Use a strong random secret (min 32 chars) — store in env variable in production jwt.secret=mySecretKey1234567890abcdefghijklmnopqrstuvwxyz # Never log passwords or tokens logging.level.org.springframework.security=WARN
bash# 1. Register a new user curl -X POST http://localhost:8080/auth/register \ -H "Content-Type: application/json" \ -d '{"username":"john","password":"pass123","role":"USER"}' # 2. Login and get token curl -X POST http://localhost:8080/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"john","password":"pass123"}' # Response: {"token": "eyJhbGciOiJIUzI1NiJ9..."} # 3. Call a protected endpoint curl http://localhost:8080/api/products \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." # 4. Try an admin endpoint as regular user (should return 403) curl -X POST http://localhost:8080/api/products \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." \ -H "Content-Type: application/json" \ -d '{"name":"Laptop","price":999}'
What's Next?