TechStackTutor Logo
HOMEBLOGKIDSABOUT USCONTACT USBOOK DEMO
Spring Boot

Spring Boot REST API with Spring Security

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.

1. Prerequisites

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>

2. How JWT Authentication Works

JWT (JSON Web Token) authentication follows this flow:

  1. Client sends credentials (username + password) to /auth/login
  2. Server validates credentials and returns a signed JWT token
  3. Client sends the token in every subsequent request: Authorization: Bearer <token>
  4. Server validates the token on each request and grants access

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.

3. Create the JWT Utility

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(); } }

4. JWT Filter

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); } }

5. Security Configuration

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.

6. Auth Controller (Login & Register)

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) {}

7. Role-Based Access on Methods

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 } }

8. application.properties

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

9. Testing the API

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?

  • Add refresh tokens to avoid re-login every 24 hours
  • Store users in PostgreSQL instead of in-memory
  • Add OAuth2 login (Google, GitHub) with Spring Security
  • Secure your API with HTTPS using SSL certificates
Back to Blog