Search code examples
javaspringspring-securityjwt

Why do we need to load user details from the DB for each request in JWT authentication with Spring Security?


I am currently implementing JWT authentication in a Spring Boot application. In most tutorials and examples, I see that the UserDetailsService.loadUserByUsername method is called for each request to validate the token. Here is a snippet of my filter:

@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
    private static final String BEARER_PREFIX = "Bearer ";
    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final int TOKEN_START_INDEX = 7;


    private final JwtService jwtService;
    private final UserDetailsService jpaUserDetailsService;

    @Override
    protected void doFilterInternal(final HttpServletRequest request,
                                    final HttpServletResponse response,
                                    final FilterChain filterChain) throws ServletException, IOException {
        String authHeader = request.getHeader(AUTHORIZATION_HEADER);
        if (hasBearerHeader(authHeader)) {
            String token = extractToken(authHeader);
            String username = jwtService.extractUsername(token);

            if (username != null && !isUserAuthenticated()) {
                UserDetails userDetails = jpaUserDetailsService.loadUserByUsername(username);
                if (jwtService.isTokenValid(token, userDetails)) {
                    UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                    );
                    authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        }

        filterChain.doFilter(request, response);
    }

    private boolean hasBearerHeader(final String authHeader) {
        return StringUtils.hasText(authHeader) && authHeader.startsWith(BEARER_PREFIX);
    }

    private String extractToken(String authHeader) {
        return Optional.of(authHeader)
                .filter(s -> s.length() > TOKEN_START_INDEX)
                .map(s -> s.substring(TOKEN_START_INDEX))
                .orElseThrow(() -> new BadCredentialsException("Invalid Authorization header: Bearer token is missing or invalid."));
    }

    private boolean isUserAuthenticated() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return authentication != null && authentication.isAuthenticated();
    }
}

JwtService:

@Service
public class JwtService {
    private static final int TOKEN_VALIDITY_HOURS = 1;
    private static final int SECONDS_PER_HOUR = 3600;
    private static final int TOKEN_VALIDITY_SECONDS = TOKEN_VALIDITY_HOURS * SECONDS_PER_HOUR;
    private static final String ALGORITHM = "HmacSHA256";

    @Value("${secret.key}")
    private String secretKey;

    public String generateToken(final UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    public String generateToken(final Map<String, ?> claims, final UserDetails userDetails) {
        Instant currentTime = Instant.now();
        Instant expirationTime = currentTime.plusSeconds(TOKEN_VALIDITY_SECONDS);

        return Jwts
                .builder()
                .claims(claims)
                .subject(userDetails.getUsername())
                .issuedAt(Date.from(currentTime))
                .expiration(Date.from(expirationTime))
                .signWith(getSecretKey(), Jwts.SIG.HS256)
                .compact();
    }

    public boolean isTokenValid(final String token, final UserDetails userDetails) {
        String usernameFromToken = extractUsername(token);
        return usernameFromToken.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

    private boolean isTokenExpired(final String token) {
        return extractExpiration(token).before(new Date());
    }

    public String extractUsername(final String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public Date extractExpiration(final String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    private <T> T extractClaim(final String token, final Function<Claims, T> extractor) {
        Claims claims = Jwts.parser()
                .verifyWith(getSecretKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();

        return extractor.apply(claims);

    }

    private SecretKey getSecretKey() {
        byte[] decodedKey = Base64.getDecoder().decode(secretKey);
        return new SecretKeySpec(decodedKey, 0, decodedKey.length, ALGORITHM);
    }
}

I am confused about the necessity of loading user details from the database with each request. If the token is issued and verified, doesn't it mean the user is already authenticated? Can't we just verify the token's signature and claims without hitting the database every time?

I appreciate any detailed explanations and recommendations on how to handle JWT authentication efficiently in Spring Security.


Solution

  • JWT authentication efficiently in Spring Security

    By not using Jwts at all is the answer.

    Jwts where never created to be used as a session replacement and most official sources recommend against using JWTs in browsers. But the sad truth is that tutorial writers are echo chambers. They read other tutorials and then make their own with their own flair to it.

    And a lot of developers don't learn by reading official docs, they just google for a tutorial.

    There is a reason why there is no prebuilt in JWTFilter in spring security, because it is not an official authentication standard. In fact, many official sources recommend straight against it.

    Okta Redis

    And the latest recommendation from IATF (the org that handles oauth2) strictly recommends against the Password grant flow (the flow that hands out tokens directly after username and password) and its going to be fully removed in oauth2.1.

    So the sad truth is that many tutorial makers care more about clicks then accuracy, and don't understand that recommendations change over time, but they don't update their content in accordance.

    JWTs should only be used for server to server communication in a one shot authorization.

    Unless you are building something that is handling 10 000s of requests per second in an enterprise environment, you wont need to micromanage requests. Just use cookie sessions, and if you need to share the sessions with a couple of more microservices, implement spring session, and use something like redis to share the sessions.

    It is already implemented in spring security and is called FormLogin its easier and a lot safer.

    Just read the Spring Security Architecture Chapter and then implement FormLogin.

    Please learn security from official documentation, and not just random bloggers.