JWT Stateless Auth vs UserDetailsService in Spring Security
Most Spring Security + JWT tutorials hit the database on every authenticated request. Here's when that's unnecessary overhead—and how building a stateless AuthPrincipal from the token payload is the cleaner approach.
The Pattern Most Tutorials Teach
Pick any Spring Security + JWT tutorial and you'll find the same filter pattern: validate the token signature, then call userDetailsService.loadUserByUsername() to fetch the user from the database and build the Authentication object. It's the default path Spring documentation points to, and for session-based applications it makes complete sense.
The problem is that most APIs using JWT aren't session-based—they're stateless. Applying a session-era pattern to a stateless API introduces a database dependency that the architecture was specifically designed to eliminate.
What the Classic Filter Actually Does
After verifying the JWT signature, the classic filter loads the user from the database to populate the security context:
// JwtAuthenticationFilter.java
String username = jwtService.extractUsername(token);
UserDetails userDetails =
userDetailsService.loadUserByUsername(username); // DB query here
if (jwtService.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
}This fires a SELECT query on every authenticated request. For a typical endpoint, that's two database hits instead of one: the authentication query, and then whatever the business logic needs. The JWT signature already cryptographically proved the token is valid and wasn't tampered with—the database call is re-confirming what cryptography already guaranteed.
There are legitimate reasons to make this call, which I'll cover shortly. But for the common case of a read-heavy REST API, it's overhead that can be eliminated entirely.
The Stateless Alternative
The stateless pattern trusts the JWT payload directly. When you issue the token, you embed the data your application needs—user ID, roles, or any other claims—inside it. Since those claims are signed with your private key, they're authoritative: if the signature is valid and the token hasn't expired, the claims are trustworthy.
Instead of loading a UserDetails from the database, the filter builds a typed principal directly from the token:
// JwtAuthenticationFilter.java
Claims claims = jwtService.extractAllClaims(token);
// No DB query — everything comes from the signed payload
AuthPrincipal principal = new AuthPrincipal(
UUID.fromString(claims.getSubject()), // userId
UserRole.valueOf(claims.get("role", String.class))
);
List<GrantedAuthority> authorities =
List.of(new SimpleGrantedAuthority("ROLE_" + principal.role()));
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
principal, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);The AuthPrincipal is a simple record or class you define yourself:
public record AuthPrincipal(UUID userId, UserRole role) {}Controllers and services can now access the authenticated user's data directly from the security context, with no additional queries needed:
@GetMapping("/me")
public ResponseEntity<UserResponse> getProfile(Authentication auth) {
AuthPrincipal principal = (AuthPrincipal) auth.getPrincipal();
UUID userId = principal.userId();
// use userId directly — no DB lookup for authentication
return ResponseEntity.ok(userService.findById(userId));
}When to Use Each Pattern
Use UserDetailsService in the filter when you need real-time user state on every request: detecting disabled accounts immediately, enforcing role changes without waiting for token expiration, or checking account lockouts. If your security model requires that disabling a user takes effect on their very next request, the database call is justified.
Use the stateless pattern when per-request database queries aren't necessary for correctness. This fits most REST APIs: the token already encodes who the user is and what they're allowed to do, and the signature guarantees that information wasn't forged. You get simpler code, lower database load, and naturally stateless behavior that scales horizontally without shared session state.
The Real Tradeoff: Immediate Revocation
The strongest argument for UserDetailsService is immediate revocation. Disable a user in the database and their next request fails—no waiting for the JWT to expire. With purely stateless auth and a 24-hour access token, a banned user retains access for up to 24 hours.
The standard stateless answer is refresh token rotation with short-lived access tokens. The idea is simple: issue access tokens that expire in 15 minutes, and a long-lived refresh token (stored securely, usually in an HttpOnly cookie) used to obtain new access tokens.
The revocation check happens at renewal time, not on every request. When the client calls /auth/refresh, the server validates the user's status against the database before issuing a new access token. If the account is disabled or the refresh token has been revoked, the renewal fails—and the user is effectively locked out within 15 minutes. That's an acceptable window for most applications.
For use cases where even a 15-minute window is too large, a token denylist stored in Redis can be checked in the filter alongside the stateless principal. You get targeted revocation without the cost of a full user lookup on every request.
Going Further: Multi-Tenant APIs
If you're building a multi-tenant API—where multiple independent organizations (tenants) share the same backend but their data is completely isolated from each other—the stateless pattern has an additional advantage worth mentioning.
In that context, UserDetailsService runs into a structural problem. The loadUserByUsername(String username) signature takes a single string, but in a multi-tenant system a username is only unique within a tenant— loadUserByUsername("admin") is ambiguous. Which organization's admin? The workarounds are a sign you're fighting the abstraction:
// Encoding tenant context into the username string — a common but ugly fix userDetailsService.loadUserByUsername(tenantId + ":" + username);
With the stateless approach, tenantId is just another JWT claim. You extend AuthPrincipal with it, and every layer of the application gets the tenant context from the security principal—no parsing, no encoding hacks, no ambiguity.
Conclusion
UserDetailsService inside a JWT filter is a pattern borrowed from stateful session-based authentication. Spring Security's default guidance promotes it because it fits the broadest case—but the broadest case isn't always your case.
For most stateless REST APIs, the tradeoffs fall on the other side: short-lived access tokens with refresh rotation handle revocation without per-request database queries; a typed AuthPrincipal gives controllers the user's identity cleanly; and the application scales horizontally without shared session state.
The "official" Spring way isn't wrong—it's designed for a different architecture. Knowing the distinction lets you pick the right tool instead of defaulting to what the tutorial showed.