Secure REST APIs with Spring Security 6 – JWT & filters

Secure REST APIs with Spring Security 6

JWT, stateless sessions, filters, and method-level security—hardening your endpoints properly

A friendly, beginner-friendly guide to building a stateless JWT setup with Spring Boot 3.

What you’ll build

  • A tiny Spring Boot 3 / Spring Security 6 REST API
  • Stateless authentication using JWT
  • A public login endpoint: POST /auth/login
  • Protected endpoints: GET /api/hello (USER) and GET /api/admin/metrics (ADMIN)
  • Sensible defaults for CORS, CSRF, password hashing, and roles

Prereqs: Java 17+, Maven or Gradle, curl/Postman.

1) Initialize the project

Create a project on start.spring.io with:

  • Spring Web
  • Spring Security
  • Lombok (optional, to reduce boilerplate)
  • Spring Boot Validation (optional)
  • JJWT (JSON Web Token library)

Maven – pom.xml (essential parts)

<dependencies>
  <!-- Web & Security -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>

  <!-- JWT -->
  <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>

  <!-- Optional -->
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
  </dependency>
</dependencies>

2) Security basics (stateless + CORS)

Spring Security 6 no longer extends WebSecurityConfigurerAdapter. Define beans instead.

// SecurityConfig.java
@Configuration
@EnableMethodSecurity // enables @PreAuthorize
public class SecurityConfig {

  private final JwtAuthFilter jwtAuthFilter;

  public SecurityConfig(JwtAuthFilter jwtAuthFilter) {
    this.jwtAuthFilter = jwtAuthFilter;
  }

  @Bean
  public UserDetailsService userDetailsService(PasswordEncoder encoder) {
    UserDetails user = User.withUsername("alice")
        .password(encoder.encode("password"))
        .roles("USER")
        .build();

    UserDetails admin = User.withUsername("admin")
        .password(encoder.encode("admin123"))
        .roles("ADMIN")
        .build();

    return new InMemoryUserDetailsManager(user, admin);
  }

  @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }

  @Bean
  AuthenticationProvider authenticationProvider(UserDetailsService uds, PasswordEncoder encoder) {
    DaoAuthenticationProvider p = new DaoAuthenticationProvider();
    p.setUserDetailsService(uds);
    p.setPasswordEncoder(encoder);
    return p;
  }

  @Bean
  AuthenticationManager authenticationManager(AuthenticationConfiguration conf) throws Exception {
    return conf.getAuthenticationManager();
  }

  @Bean
  SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationProvider provider) throws Exception {
    http
      .csrf(csrf -> csrf.disable()) // stateless API → disable CSRF
      .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
      .cors(Customizer.withDefaults())
      .authorizeHttpRequests(auth -> auth
        .requestMatchers("/auth/**", "/actuator/health").permitAll()
        .anyRequest().authenticated()
      )
      .authenticationProvider(provider)
      .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

    return http.build();
  }

  // Basic CORS – set to your frontend origins
  @Bean
  CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration cfg = new CorsConfiguration();
    cfg.setAllowedOrigins(List.of("http://localhost:3000", "https://prodevaihub.com"));
    cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
    cfg.setAllowedHeaders(List.of("Authorization","Content-Type"));
    cfg.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", cfg);
    return source;
  }
}

3) JWT service

// JwtService.java
@Service
public class JwtService {

  @Value("${security.jwt.secret}")        private String secretBase64;
  @Value("${security.jwt.expiration-ms:3600000}") private long expirationMs;

  private Key key() {
    byte[] keyBytes = Decoders.BASE64.decode(secretBase64);
    return Keys.hmacShaKeyFor(keyBytes); // HS256
  }

  public String generateToken(UserDetails user) {
    Date now = new Date();
    Date exp = new Date(now.getTime() + expirationMs);

    return Jwts.builder()
        .setSubject(user.getUsername())
        .claim("roles", user.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority).toList())
        .setIssuedAt(now)
        .setExpiration(exp)
        .signWith(key(), SignatureAlgorithm.HS256)
        .compact();
  }

  public String extractUsername(String token) {
    return Jwts.parserBuilder().setSigningKey(key()).build()
        .parseClaimsJws(token).getBody().getSubject();
  }

  public boolean isTokenValid(String token, UserDetails user) {
    String username = extractUsername(token);
    Date exp = Jwts.parserBuilder().setSigningKey(key()).build()
        .parseClaimsJws(token).getBody().getExpiration();
    return username.equals(user.getUsername()) && exp.after(new Date());
  }
}

Generate a strong base64 secret (≥256-bit for HS256):

openssl rand -base64 32

application.yml:

security:
  jwt:
    secret: "PASTE_YOUR_BASE64_SECRET_HERE"
    expiration-ms: 3600000 # 1 hour

4) JWT filter

// JwtAuthFilter.java
@Component
public class JwtAuthFilter extends OncePerRequestFilter {

  private final JwtService jwtService;
  private final UserDetailsService uds;

  public JwtAuthFilter(JwtService jwtService, UserDetailsService uds) {
    this.jwtService = jwtService;
    this.uds = uds;
  }

  @Override
  protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                  FilterChain chain) throws ServletException, IOException {
    String header = req.getHeader("Authorization");

    if (header != null && header.startsWith("Bearer ")) {
      String token = header.substring(7);
      String username = jwtService.extractUsername(token);

      if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
        UserDetails user = uds.loadUserByUsername(username);
        if (jwtService.isTokenValid(token, user)) {
          UsernamePasswordAuthenticationToken auth =
              new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
          auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
          SecurityContextHolder.getContext().setAuthentication(auth);
        }
      }
    }

    chain.doFilter(req, res);
  }
}

5) Controllers: login + secured endpoints

// AuthController.java
@RestController
@RequestMapping("/auth")
public class AuthController {

  private final AuthenticationManager authManager;
  private final JwtService jwtService;

  public AuthController(AuthenticationManager am, JwtService jwtService) {
    this.authManager = am;
    this.jwtService = jwtService;
  }

  @PostMapping("/login")
  public Map<String,String> login(@RequestBody AuthRequest req) {
    Authentication auth = authManager.authenticate(
        new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword()));
    UserDetails user = (UserDetails) auth.getPrincipal();
    String token = jwtService.generateToken(user);
    return Map.of("tokenType", "Bearer", "accessToken", token);
  }
}

@Data
class AuthRequest { private String username; private String password; }

6) Try it with curl

  1. Login to get a token:
curl -X POST http://localhost:8080/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"password"}'

2. Call a protected endpoint:

curl http://localhost:8080/api/hello \
  -H "Authorization: Bearer YOUR_JWT_HERE"

3. Admin endpoint (requires ROLE_ADMIN):

curl http://localhost:8080/api/admin/metrics \
  -H "Authorization: Bearer ADMIN_JWT_HERE"

7) Three key concepts (in simple words)

Stateless – the server keeps no session. Every request must carry the token.
CSRF – mostly relevant to cookie-based browser forms; for stateless APIs we typically disable it.
CORS – explicitly allow your frontend origins, methods, and headers (notably Authorization).

8) Production checklist

  • HTTPS everywhere.
  • Secret management: environment variables, Vault, GitHub Actions secrets — never hard-code.
  • Key rotation: periodically rotate signing keys; support multiple keys if needed.
  • Short access-token lifetime + Refresh token flow.
  • Password hashing: BCrypt (already configured).
  • Input validation & sanitization.
  • Rate limiting, logging, and alerting.
  • Lock down Actuator endpoints.
  • Review OWASP API Security and Authentication Cheat Sheets.

9) Common “gotchas”

  • 403 even though I’m logged in → check the role in your JWT vs. the required role (ROLE_...) and your @PreAuthorize / authorizeHttpRequests rules.
  • Works locally, fails in prod → CORS not allowing your origin, or headers stripped by a proxy, or HTTP instead of HTTPS.
  • Invalid JWT signature → wrong key or length; for HS256 use at least a 256-bit base64 secret.

10) Where to go next?

  • Replace the in-memory users with JPA and a real database.
  • Add /auth/register with email/password validation.
  • Implement a refresh token endpoint.
  • Centralize error handling with @ControllerAdvice (clean 401/403/422 responses).

Wrap-up

With Spring Security 6 the setup is clean: declarative config, a small JWT filter, and clear boundaries between authentication and authorization. Start simple, keep it stateless, and add features as your needs grow.

Leave a Comment

Your email address will not be published. Required fields are marked *