Spring Boot
Stateless Authentication
Stateless authentication eliminates server-side session storage — every request carries all the information needed to authenticate it. JWT is the dominant stateless mechanism: the server signs a token on login, the client sends it on every subsequent request, and the server verifies the signature without a database lookup. This entry covers the complete stateless flow, token structure, filter chain, security context propagation, and API key authentication as an alternative stateless strategy.
Stateless Security Filter Chain
A stateless filter chain disables session creation, disables CSRF (no cookies to forge), and adds a JWT filter before Spring Security's authentication filter. Every request is independently authenticated by the JWT filter without consulting any session store.
Java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class StatelessSecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
private final CustomUserDetailsService userDetailsService;
private final JwtAuthenticationEntryPoint entryPoint;
private final JwtAccessDeniedHandler accessDeniedHandler;
private final PasswordEncoder passwordEncoder;
@Bean
public DaoAuthenticationProvider authProvider() {
DaoAuthenticationProvider p =
new DaoAuthenticationProvider();
p.setUserDetailsService(userDetailsService);
p.setPasswordEncoder(passwordEncoder);
p.setHideUserNotFoundExceptions(true);
return p;
}
@Bean
public AuthenticationManager authManager(
AuthenticationConfiguration cfg) throws Exception {
return cfg.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)
throws Exception {
return http
// ── No cookies → no CSRF surface ──────────────────────────
.csrf(AbstractHttpConfigurer::disable)
// ── No session — never create, never use ──────────────────
.sessionManagement(sm -> sm
.sessionCreationPolicy(
SessionCreationPolicy.STATELESS))
// ── Auth and access errors → structured JSON ───────────────
.exceptionHandling(ex -> ex
.authenticationEntryPoint(entryPoint)
.accessDeniedHandler(accessDeniedHandler))
// ── Wire authentication provider ───────────────────────────
.authenticationProvider(authProvider())
// ── URL-level access rules ─────────────────────────────────
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/v1/auth/login",
"/api/v1/auth/register",
"/api/v1/auth/refresh",
"/api/v1/public/**",
"/actuator/health"
).permitAll()
.requestMatchers("/api/v1/admin/**")
.hasRole("ADMIN")
.anyRequest().authenticated())
// ── JWT filter runs before standard auth filter ────────────
.addFilterBefore(jwtFilter,
UsernamePasswordAuthenticationFilter.class)
.build();
}
}Complete Stateless Auth Flow
The stateless flow has four steps: register, login to receive tokens, send the access token on each request, and use the refresh token to renew the access token when it expires. The server holds no state between steps — each step is independently verifiable from the token alone.
Java
// ── Step 1: Registration ──────────────────────────────────────────────
// POST /api/v1/auth/register
// Body: { "email": "alice@example.com", "password": "...", "name": "Alice" }
// Response: 201 Created { "id": 1, "email": "...", "name": "..." }
// ── Step 2: Login — server issues tokens ──────────────────────────────
// POST /api/v1/auth/login
// Body: { "email": "alice@example.com", "password": "..." }
// Response: {
// "accessToken": "eyJhbGciOiJIUzI1NiJ9...", // 15 min
// "refreshToken": "eyJhbGciOiJIUzI1NiJ9...", // 7 days
// "tokenType": "Bearer",
// "expiresIn": 900
// }
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authManager;
private final JwtService jwtService;
private final RefreshTokenService refreshService;
private final RegistrationService registrationService;
@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public UserResponse register(
@RequestBody @Valid RegisterRequest request) {
return registrationService.register(request);
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(
@RequestBody @Valid LoginRequest request) {
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.email(), request.password()));
AppUser principal = (AppUser) auth.getPrincipal();
String access = jwtService.generateAccessToken(principal);
String refresh = refreshService.create(principal);
return ResponseEntity.ok(new AuthResponse(
access, refresh, "Bearer", 900L,
principal.getId(), principal.getEmail(),
principal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList()));
}
// ── Step 3: Every API request carries the token ───────────────────
// GET /api/v1/profile
// Headers: Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
// → JwtAuthenticationFilter validates token, populates SecurityContext
// → Controller sees authenticated principal
// ── Step 4: Token refresh ──────────────────────────────────────────
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(
@RequestBody @Valid RefreshRequest request) {
String email = refreshService.validate(
request.refreshToken());
AppUser user = (AppUser) userDetailsService
.loadUserByUsername(email);
String newAccess = jwtService.generateAccessToken(user);
return ResponseEntity.ok(new AuthResponse(
newAccess, request.refreshToken(),
"Bearer", 900L,
user.getId(), user.getEmail(),
user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList()));
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(
@RequestBody @Valid RefreshRequest request) {
refreshService.revoke(request.refreshToken());
return ResponseEntity.noContent().build();
}
}SecurityContext in Stateless Requests
In a stateless application the SecurityContext is populated per-request by the JWT filter and cleared after the response. Spring Security's SecurityContextHolder uses a ThreadLocal strategy — the context is available anywhere in the call stack within that request thread, including service and repository layers.
Java
// ── Accessing the principal anywhere in the call stack ───────────────
// In a controller — prefer @AuthenticationPrincipal
@GetMapping("/profile")
public ResponseEntity<ProfileResponse> profile(
@AuthenticationPrincipal AppUser principal) {
return ResponseEntity.ok(
profileService.findById(principal.getId()));
}
// In a service — programmatic access when injection is not available
@Service
public class AuditService {
public String getCurrentUserEmail() {
Authentication auth = SecurityContextHolder
.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return "anonymous";
}
return auth.getName();
}
public Optional<AppUser> getCurrentUser() {
return Optional.ofNullable(
SecurityContextHolder.getContext()
.getAuthentication())
.filter(Authentication::isAuthenticated)
.map(Authentication::getPrincipal)
.filter(p -> p instanceof AppUser)
.map(p -> (AppUser) p);
}
}
// ── SecurityContext propagation to async threads ──────────────────────
// By default SecurityContext is NOT propagated to @Async threads.
// Use DelegatingSecurityContextAsyncTaskExecutor:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor =
new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.initialize();
// Wrap with security context propagation
return new DelegatingSecurityContextAsyncTaskExecutor(
executor);
}
}
// ── SecurityContext in scheduled tasks ────────────────────────────────
// Scheduled tasks have no security context — set one explicitly:
@Service
@RequiredArgsConstructor
public class ScheduledReportService {
@Scheduled(cron = "0 0 2 * * *")
public void generateNightlyReport() {
// Set a system principal for the scheduled task
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
"system", null,
List.of(new SimpleGrantedAuthority("ROLE_SYSTEM")));
SecurityContextHolder.getContext()
.setAuthentication(auth);
try {
reportService.generateAll();
} finally {
SecurityContextHolder.clearContext();
}
}
}API Key Authentication
API key authentication is an alternative stateless strategy for machine-to-machine communication. Each client is issued an opaque key stored (hashed) in the database. A filter extracts the key from the X-API-Key header, validates it against the database, and populates the SecurityContext — no JWT involved.
Java
// ── API key entity ────────────────────────────────────────────────────
@Entity
@Table(name = "api_keys")
@Getter @Setter @NoArgsConstructor
public class ApiKey {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "key_hash", nullable = false, unique = true)
private String keyHash; // SHA-256 of the raw key
@Column(nullable = false, length = 100)
private String name; // human-readable label
@Column(name = "client_id", nullable = false)
private String clientId;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "api_key_scopes",
joinColumns = @JoinColumn(name = "api_key_id"))
@Column(name = "scope")
private Set<String> scopes = new HashSet<>();
@Column(nullable = false)
private boolean active = true;
@Column(name = "expires_at")
private Instant expiresAt;
@Column(name = "last_used_at")
private Instant lastUsedAt;
}
// ── API key service ───────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class ApiKeyService {
private final ApiKeyRepository apiKeyRepo;
public Optional<ApiKey> validate(String rawKey) {
String hash = hashKey(rawKey);
return apiKeyRepo.findByKeyHashAndActiveTrue(hash)
.filter(k -> k.getExpiresAt() == null
|| k.getExpiresAt().isAfter(Instant.now()))
.map(k -> {
k.setLastUsedAt(Instant.now());
return apiKeyRepo.save(k);
});
}
public String generateKey(String clientId, String name,
Set<String> scopes) {
String raw = UUID.randomUUID().toString().replace("-", "")
+ UUID.randomUUID().toString().replace("-", "");
ApiKey key = new ApiKey();
key.setKeyHash(hashKey(raw));
key.setClientId(clientId);
key.setName(name);
key.setScopes(scopes);
apiKeyRepo.save(key);
return raw; // return once — never stored in plain text
}
private String hashKey(String raw) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(raw.getBytes(
StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
// ── API key filter ────────────────────────────────────────────────────
@Component
@RequiredArgsConstructor
@Slf4j
public class ApiKeyAuthFilter extends OncePerRequestFilter {
private final ApiKeyService apiKeyService;
private static final String API_KEY_HEADER = "X-API-Key";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String rawKey = request.getHeader(API_KEY_HEADER);
if (rawKey == null || rawKey.isBlank()) {
chain.doFilter(request, response);
return;
}
apiKeyService.validate(rawKey).ifPresentOrElse(
apiKey -> {
List<GrantedAuthority> authorities =
apiKey.getScopes().stream()
.map(s -> new SimpleGrantedAuthority(
"SCOPE_" + s))
.collect(Collectors.toList());
authorities.add(new SimpleGrantedAuthority(
"ROLE_API_CLIENT"));
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
apiKey.getClientId(), null, authorities);
auth.setDetails(
new WebAuthenticationDetailsSource()
.buildDetails(request));
SecurityContextHolder.getContext()
.setAuthentication(auth);
log.debug("API key auth: client={}",
apiKey.getClientId());
},
() -> log.warn("Invalid API key presented from {}",
request.getRemoteAddr())
);
chain.doFilter(request, response);
}
}Token Introspection Endpoint
Expose a token introspection endpoint so internal services can verify a JWT without sharing the signing secret. Return the token's claims — subject, roles, expiry — in a standardised format. Protect the endpoint with an API key or IP allowlist.
Java
// ── Introspection response ────────────────────────────────────────────
public record IntrospectionResponse(
boolean active,
String sub, // subject (email)
Long userId,
List<String> roles,
Instant iat, // issued at
Instant exp, // expires at
String iss // issuer
) {
public static IntrospectionResponse inactive() {
return new IntrospectionResponse(
false, null, null, null, null, null, null);
}
}
// ── Introspection controller ──────────────────────────────────────────
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class TokenIntrospectionController {
private final JwtService jwtService;
// Protect with API key — internal services only
@PostMapping("/introspect")
public ResponseEntity<IntrospectionResponse> introspect(
@RequestParam String token) {
try {
String subject = jwtService.extractSubject(token);
Long userId = jwtService.extractClaim(
token, claims -> claims.get("userId", Long.class));
List<String> roles = jwtService.extractClaim(
token, claims -> claims.get("roles", List.class));
Instant iat = jwtService.extractClaim(
token, claims ->
claims.getIssuedAt().toInstant());
Instant exp = jwtService.extractClaim(
token, claims ->
claims.getExpiration().toInstant());
if (exp.isBefore(Instant.now())) {
return ResponseEntity.ok(
IntrospectionResponse.inactive());
}
return ResponseEntity.ok(new IntrospectionResponse(
true, subject, userId, roles,
iat, exp, jwtService.getIssuer()));
} catch (JwtException ex) {
return ResponseEntity.ok(
IntrospectionResponse.inactive());
}
}
}