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