Spring BootJWT Authentication
Spring Boot

JWT Authentication

JSON Web Tokens (JWT) provide stateless authentication for REST APIs. The server issues a signed token on login; the client sends it in the Authorization header on every subsequent request. Spring Security validates the token in a filter before the request reaches any controller. This entry covers token generation, validation, the authentication filter, refresh tokens, and token revocation.

Dependencies and Setup

Add the JJWT library for JWT creation and parsing. Configure the secret key and expiry times in application.yml. The secret must be at least 256 bits for HS256 — generate it with a secure random source and store it in an environment variable or secrets manager, never in source control.
XML
<!-- pom.xml — JJWT (Java JWT library) -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

# ── application.yml ────────────────────────────────────────────────────
app:
  jwt:
    secret: ${JWT_SECRET}          # min 64 hex chars (256-bit key)
    access-token-expiry:  900      # 15 minutes (seconds)
    refresh-token-expiry: 604800   # 7 days (seconds)
    issuer: myapp

# ── Generate a secure secret ──────────────────────────────────────────
# openssl rand -hex 64
# Store the output in JWT_SECRET environment variable

JwtService — Token Generation and Validation

JwtService centralises all JWT operations: building access tokens with claims, parsing and validating tokens, and extracting the subject. Use HS256 (HMAC-SHA-256) for single-server deployments and RS256 (RSA) for microservices where multiple services need to verify tokens without sharing a secret.
Java
@Service
@Slf4j
public class JwtService {

    private final SecretKey  secretKey;
    private final long       accessTokenExpiry;
    private final long       refreshTokenExpiry;
    private final String     issuer;

    public JwtService(
            @Value("${app.jwt.secret}")                String secret,
            @Value("${app.jwt.access-token-expiry}")   long   accessExpiry,
            @Value("${app.jwt.refresh-token-expiry}")  long   refreshExpiry,
            @Value("${app.jwt.issuer}")                String issuer) {
        this.secretKey           = Keys.hmacShaKeyFor(
            Decoders.BASE64.decode(secret));
        this.accessTokenExpiry   = accessExpiry;
        this.refreshTokenExpiry  = refreshExpiry;
        this.issuer              = issuer;
    }

    // ── Generate access token ─────────────────────────────────────────
    public String generateAccessToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("roles", userDetails.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .toList());
        if (userDetails instanceof AuthenticatedUser u) {
            claims.put("userId", u.getId());
        }
        return buildToken(claims, userDetails.getUsername(),
            accessTokenExpiry);
    }

    // ── Generate refresh token (minimal claims) ───────────────────────
    public String generateRefreshToken(UserDetails userDetails) {
        return buildToken(Map.of(), userDetails.getUsername(),
            refreshTokenExpiry);
    }

    private String buildToken(Map<String, Object> claims,
                               String subject, long expirySeconds) {
        Instant now = Instant.now();
        return Jwts.builder()
            .claims(claims)
            .subject(subject)
            .issuer(issuer)
            .issuedAt(Date.from(now))
            .expiration(Date.from(
                now.plusSeconds(expirySeconds)))
            .id(UUID.randomUUID().toString())   // jti — unique per token
            .signWith(secretKey)
            .compact();
    }

    // ── Extract subject (email) ───────────────────────────────────────
    public String extractSubject(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // ── Validate token ────────────────────────────────────────────────
    public boolean isValid(String token, UserDetails userDetails) {
        try {
            String subject = extractSubject(token);
            return subject.equals(userDetails.getUsername())
                && !isExpired(token);
        } catch (JwtException ex) {
            log.warn("JWT validation failed: {}", ex.getMessage());
            return false;
        }
    }

    public boolean isExpired(String token) {
        return extractClaim(token, Claims::getExpiration)
            .before(new Date());
    }

    public <T> T extractClaim(String token,
            Function<Claims, T> resolver) {
        Claims claims = Jwts.parser()
            .verifyWith(secretKey)
            .build()
            .parseSignedClaims(token)
            .getPayload();
        return resolver.apply(claims);
    }
}

JWT Authentication Filter

A OncePerRequestFilter intercepts every request, extracts the Bearer token from the Authorization header, validates it, and populates the SecurityContext. If the token is missing or invalid, the filter does nothing — Spring Security's access control handles the 401 response downstream.
Java
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService               jwtService;
    private final CustomUserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest  request,
                                    HttpServletResponse response,
                                    FilterChain         chain)
            throws ServletException, IOException {

        final String authHeader = request.getHeader(
            HttpHeaders.AUTHORIZATION);

        // ── No Bearer token — skip this filter ────────────────────────
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }

        final String jwt   = authHeader.substring(7);
        final String email;

        try {
            email = jwtService.extractSubject(jwt);
        } catch (JwtException ex) {
            log.warn("Failed to extract JWT subject: {}",
                ex.getMessage());
            chain.doFilter(request, response);
            return;
        }

        // ── Already authenticated in this request ─────────────────────
        if (email != null && SecurityContextHolder.getContext()
                .getAuthentication() == null) {

            UserDetails userDetails =
                userDetailsService.loadUserByUsername(email);

            if (jwtService.isValid(jwt, userDetails)) {
                UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,
                        userDetails.getAuthorities()
                    );
                authToken.setDetails(
                    new WebAuthenticationDetailsSource()
                        .buildDetails(request));

                SecurityContextHolder.getContext()
                    .setAuthentication(authToken);

                log.debug("Authenticated user: {} for {}",
                    email, request.getRequestURI());
            }
        }

        chain.doFilter(request, response);
    }

    // ── Skip filter for auth endpoints ────────────────────────────────
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String path = request.getServletPath();
        return path.startsWith("/api/v1/auth/");
    }
}

Auth Controller — Login and Token Endpoint

The login endpoint authenticates credentials, generates an access token and a refresh token, and returns them to the client. The refresh endpoint accepts a valid refresh token and issues a new access token without requiring the user to log in again.
Java
// ── Auth DTOs ─────────────────────────────────────────────────────────
public record LoginRequest(
    @NotBlank @Email String email,
    @NotBlank        String password
) {}

public record AuthResponse(
    String  accessToken,
    String  refreshToken,
    String  tokenType,        // "Bearer"
    long    expiresIn,        // seconds
    Long    userId,
    String  email,
    List<String> roles
) {}

public record RefreshRequest(
    @NotBlank String refreshToken
) {}

// ── Auth controller ───────────────────────────────────────────────────
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@Slf4j
public class AuthController {

    private final AuthenticationManager authManager;
    private final JwtService            jwtService;
    private final CustomUserDetailsService userDetailsService;
    private final RefreshTokenService   refreshTokenService;

    @Value("${app.jwt.access-token-expiry}")
    private long accessTokenExpiry;

    // ── POST /api/v1/auth/login ───────────────────────────────────────
    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(
            @RequestBody @Valid LoginRequest request) {

        Authentication auth = authManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                request.email(), request.password()));

        AuthenticatedUser principal =
            (AuthenticatedUser) auth.getPrincipal();

        String accessToken  = jwtService.generateAccessToken(principal);
        String refreshToken = refreshTokenService.create(principal);

        return ResponseEntity.ok(new AuthResponse(
            accessToken,
            refreshToken,
            "Bearer",
            accessTokenExpiry,
            principal.getId(),
            principal.getEmail(),
            principal.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .toList()
        ));
    }

    // ── POST /api/v1/auth/refresh ─────────────────────────────────────
    @PostMapping("/refresh")
    public ResponseEntity<AuthResponse> refresh(
            @RequestBody @Valid RefreshRequest request) {

        String email = refreshTokenService.validate(
            request.refreshToken());

        UserDetails userDetails =
            userDetailsService.loadUserByUsername(email);

        String newAccessToken =
            jwtService.generateAccessToken(userDetails);

        return ResponseEntity.ok(new AuthResponse(
            newAccessToken,
            request.refreshToken(),   // reuse refresh token
            "Bearer",
            accessTokenExpiry,
            ((AuthenticatedUser) userDetails).getId(),
            userDetails.getUsername(),
            userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .toList()
        ));
    }

    // ── POST /api/v1/auth/logout ──────────────────────────────────────
    @PostMapping("/logout")
    public ResponseEntity<Void> logout(
            @RequestBody @Valid RefreshRequest request) {
        refreshTokenService.revoke(request.refreshToken());
        return ResponseEntity.noContent().build();
    }
}

Refresh Token Service

Refresh tokens are long-lived and stored server-side (database or Redis) so they can be revoked. The access token is short-lived and stateless — it cannot be revoked, which is why its expiry should be short (5–15 minutes). Rotate refresh tokens on each use to detect token theft.
Java
// ── Refresh token entity ─────────────────────────────────────────────
@Entity
@Table(name = "refresh_tokens")
@Getter @Setter @NoArgsConstructor
public class RefreshToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 512)
    private String token;

    @Column(name = "user_email", nullable = false)
    private String userEmail;

    @Column(name = "expires_at", nullable = false)
    private Instant expiresAt;

    @Column(nullable = false)
    private boolean revoked = false;

    @Column(name = "created_at", nullable = false)
    private Instant createdAt;
}

public interface RefreshTokenRepository
        extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByToken(String token);
    void deleteByUserEmail(String email);
    void deleteByExpiresAtBefore(Instant now);
}

// ── Refresh token service ─────────────────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class RefreshTokenService {

    private final RefreshTokenRepository tokenRepo;
    private final JwtService             jwtService;

    @Value("${app.jwt.refresh-token-expiry}")
    private long refreshTokenExpiry;

    // ── Create and store a refresh token ─────────────────────────────
    @Transactional
    public String create(UserDetails userDetails) {
        String tokenValue = jwtService
            .generateRefreshToken(userDetails);

        RefreshToken token = new RefreshToken();
        token.setToken(tokenValue);
        token.setUserEmail(userDetails.getUsername());
        token.setExpiresAt(
            Instant.now().plusSeconds(refreshTokenExpiry));
        token.setCreatedAt(Instant.now());
        tokenRepo.save(token);

        return tokenValue;
    }

    // ── Validate and return subject ───────────────────────────────────
    @Transactional
    public String validate(String tokenValue) {
        RefreshToken token = tokenRepo.findByToken(tokenValue)
            .orElseThrow(() ->
                new InvalidTokenException("Refresh token not found"));

        if (token.isRevoked()) {
            // Possible token reuse attack — revoke all tokens for user
            revokeAll(token.getUserEmail());
            throw new InvalidTokenException(
                "Refresh token has been revoked");
        }

        if (token.getExpiresAt().isBefore(Instant.now())) {
            tokenRepo.delete(token);
            throw new InvalidTokenException("Refresh token expired");
        }

        return token.getUserEmail();
    }

    // ── Revoke a single token ─────────────────────────────────────────
    @Transactional
    public void revoke(String tokenValue) {
        tokenRepo.findByToken(tokenValue).ifPresent(t -> {
            t.setRevoked(true);
            tokenRepo.save(t);
        });
    }

    // ── Revoke all tokens for a user (logout all devices) ────────────
    @Transactional
    public void revokeAll(String email) {
        tokenRepo.deleteByUserEmail(email);
        log.info("Revoked all refresh tokens for: {}", email);
    }

    // ── Scheduled cleanup of expired tokens ───────────────────────────
    @Scheduled(cron = "0 0 3 * * *")   // 3 AM daily
    @Transactional
    public void cleanExpired() {
        tokenRepo.deleteByExpiresAtBefore(Instant.now());
        log.info("Cleaned expired refresh tokens");
    }
}

JWT Exception Handling

Spring Security exceptions thrown before the controller — ExpiredJwtException, MalformedJwtException, and AuthenticationException — need dedicated handlers. Implement AuthenticationEntryPoint and AccessDeniedHandler to return structured JSON error responses instead of Spring's default HTML error page.
Java
// ── 401 AuthenticationEntryPoint ─────────────────────────────────────
@Component
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint
        implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest  req,
                         HttpServletResponse res,
                         AuthenticationException ex)
            throws IOException {
        res.setStatus(HttpStatus.UNAUTHORIZED.value());
        res.setContentType(MediaType.APPLICATION_JSON_VALUE);
        objectMapper.writeValue(res.getWriter(),
            ErrorResponse.of(401, "Unauthorized",
                "Authentication required to access this resource",
                req.getRequestURI()));
    }
}

// ── 403 AccessDeniedHandler ───────────────────────────────────────────
@Component
@RequiredArgsConstructor
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    @Override
    public void handle(HttpServletRequest  req,
                       HttpServletResponse res,
                       AccessDeniedException ex)
            throws IOException {
        res.setStatus(HttpStatus.FORBIDDEN.value());
        res.setContentType(MediaType.APPLICATION_JSON_VALUE);
        objectMapper.writeValue(res.getWriter(),
            ErrorResponse.of(403, "Forbidden",
                "Insufficient permissions to access this resource",
                req.getRequestURI()));
    }
}

// ── Register in SecurityFilterChain ───────────────────────────────────
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)
        throws Exception {
    return http
        .csrf(AbstractHttpConfigurer::disable)
        .sessionManagement(sm -> sm
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .exceptionHandling(ex -> ex
            .authenticationEntryPoint(authEntryPoint)
            .accessDeniedHandler(accessDeniedHandler))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/v1/auth/**").permitAll()
            .anyRequest().authenticated())
        .addFilterBefore(jwtFilter,
            UsernamePasswordAuthenticationFilter.class)
        .build();
}

// ── Global handler for JWT exceptions from the filter ─────────────────
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ExpiredJwtException.class)
    public ResponseEntity<ErrorResponse> handleExpired(
            ExpiredJwtException ex, HttpServletRequest req) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
            .body(ErrorResponse.of(401, "Token Expired",
                "JWT token has expired. Please login again.",
                req.getRequestURI()));
    }

    @ExceptionHandler({MalformedJwtException.class,
                       UnsupportedJwtException.class,
                       SignatureException.class})
    public ResponseEntity<ErrorResponse> handleInvalidToken(
            JwtException ex, HttpServletRequest req) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
            .body(ErrorResponse.of(401, "Invalid Token",
                "JWT token is invalid.",
                req.getRequestURI()));
    }
}