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) andGET /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
- 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)
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/authorizeHttpRequestsrules. - 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.

