JWT Authentication: Stateless Token-Based Security

What Is JWT?

JWT (JSON Web Token, RFC 7519) is a compact, URL-safe token format for representing claims between two parties. It is the standard for stateless REST API authentication — the server issues a signed token at login, and the client presents it on every subsequent request. No session, no cookie, no server-side state.

JWT Structure

A JWT has three Base64URL-encoded parts separated by dots:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIsInJvbGVzIjpbIlJPTEVfVVNFUiJdfQ.signature
      Header                            Payload                               Signature
block-beta
  columns 3
  A["**Header**\n{\n  alg: HS256,\n  typ: JWT\n}"]:1
  B["**Payload (Claims)**\n{\n  sub: alice,\n  iat: 1714000000,\n  exp: 1714003600,\n  roles: [ROLE_USER]\n}"]:1
  C["**Signature**\nHMAC-SHA256(\n  base64(header) + '.' +\n  base64(payload),\n  secret\n)"]:1

Header: algorithm (HS256, RS256) and token type (JWT). Payload: claims — sub (subject/username), iat (issued at), exp (expiry), plus custom claims (roles, user ID). Signature: prevents tampering. The server verifies the signature before trusting the payload.


The JWT Authentication Flow

sequenceDiagram
    participant Client
    participant AuthController as POST /api/auth/login
    participant AM as AuthenticationManager
    participant JwtService
    participant JwtFilter as JwtAuthenticationFilter
    participant AuthFilter as AuthorizationFilter
    participant API as Protected API

    Note over Client,API: Step 1: Login
    Client->>AuthController: POST /api/auth/login\n{email, password}
    AuthController->>AM: authenticate(token)
    AM-->>AuthController: Authentication (success)
    AuthController->>JwtService: generateToken(userDetails)
    JwtService-->>AuthController: "eyJ..."
    AuthController-->>Client: 200 OK {accessToken, refreshToken}

    Note over Client,API: Step 2: Access Protected Resource
    Client->>JwtFilter: GET /api/products\nAuthorization: Bearer eyJ...
    JwtFilter->>JwtService: extractUsername(token)
    JwtService-->>JwtFilter: "alice"
    JwtFilter->>JwtFilter: loadUserByUsername("alice")
    JwtFilter->>JwtService: isTokenValid(token, userDetails)
    JwtService-->>JwtFilter: true
    JwtFilter->>JwtFilter: setAuthentication(alice) in SecurityContext
    JwtFilter->>AuthFilter: request continues
    AuthFilter-->>JwtFilter: allowed
    JwtFilter->>API: request proceeds
    API-->>Client: 200 OK {products}

Dependencies

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.5</version>
    <scope>runtime</scope>
</dependency>

JwtService: Token Generation and Validation

@Service
public class JwtService {

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

    @Value("${app.jwt.expiration-ms:3600000}") // 1 hour default
    private long jwtExpiration;

    // Generate token for a UserDetails
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        // Add roles as a claim
        claims.put("roles", userDetails.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .toList());
        return buildToken(claims, userDetails.getUsername(), jwtExpiration);
    }

    // Generate token with extra claims
    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return buildToken(extraClaims, userDetails.getUsername(), jwtExpiration);
    }

    private String buildToken(Map<String, Object> claims, String subject, long expiration) {
        return Jwts.builder()
            .claims(claims)
            .subject(subject)
            .issuedAt(new Date(System.currentTimeMillis()))
            .expiration(new Date(System.currentTimeMillis() + expiration))
            .signWith(getSigningKey())
            .compact();
    }

    // Validate token against a UserDetails
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

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

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

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }

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

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}
# application.yml
app:
  jwt:
    secret: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970  # 256-bit Base64
    expiration-ms: 3600000   # 1 hour
    refresh-expiration-ms: 604800000  # 7 days

Generate a secure secret:

openssl rand -base64 32

JwtAuthenticationFilter

This filter runs before UsernamePasswordAuthenticationFilter. It reads the JWT from the Authorization: Bearer header, validates it, and sets the authentication in the SecurityContext:

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

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

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

        // No token — let the request continue (AnonymousAuthenticationFilter will set anonymous)
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        final String jwt = authHeader.substring(7);

        try {
            final String username = jwtService.extractUsername(jwt);

            // Only authenticate if not already authenticated (avoid double processing)
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                if (jwtService.isTokenValid(jwt, userDetails)) {
                    UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                        );
                    authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                    );
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        } catch (JwtException e) {
            // Invalid token — log and continue unauthenticated
            // Don't send 401 here — let ExceptionTranslationFilter handle it
            logger.debug("JWT validation failed: " + e.getMessage());
        }

        filterChain.doFilter(request, response);
    }
}

Login Endpoint

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(@RequestBody @Valid LoginRequest request) {
        // Authenticate — throws AuthenticationException if credentials wrong
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword())
        );

        AppUserDetails user = (AppUserDetails) authentication.getPrincipal();

        String accessToken = jwtService.generateToken(user);
        String refreshToken = jwtService.generateRefreshToken(user);

        return ResponseEntity.ok(new AuthResponse(
            accessToken,
            refreshToken,
            user.getUsername(),
            user.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .toList()
        ));
    }

    @PostMapping("/register")
    public ResponseEntity<AuthResponse> register(@RequestBody @Valid RegisterRequest request) {
        // Registration logic — validate, hash password, save user, generate tokens
        AppUserDetails user = userRegistrationService.register(request);
        String accessToken = jwtService.generateToken(user);
        String refreshToken = jwtService.generateRefreshToken(user);
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(new AuthResponse(accessToken, refreshToken, user.getUsername(), List.of()));
    }
}
public record LoginRequest(
    @NotBlank @Email String email,
    @NotBlank @Size(min = 8) String password
) {}

public record AuthResponse(
    String accessToken,
    String refreshToken,
    String username,
    List<String> roles
) {}

SecurityFilterChain for JWT

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final AppUserDetailsService userDetailsService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authenticationProvider(authenticationProvider())
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(jwtAuthEntryPoint())
                .accessDeniedHandler(jwtAccessDeniedHandler())
            );
        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
            throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationEntryPoint jwtAuthEntryPoint() {
        return (request, response, ex) -> {
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.getWriter().write("{\"error\": \"Unauthorized\", \"message\": \""
                + ex.getMessage() + "\"}");
        };
    }

    @Bean
    public AccessDeniedHandler jwtAccessDeniedHandler() {
        return (request, response, ex) -> {
            response.setStatus(HttpStatus.FORBIDDEN.value());
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.getWriter().write("{\"error\": \"Forbidden\"}");
        };
    }
}

JWT Signing: HS256 vs RS256

AlgorithmKey typeUse when
HS256 (HMAC-SHA256)Symmetric — one shared secretSingle service (same app signs and verifies)
RS256 (RSA-SHA256)Asymmetric — private key signs, public key verifiesMultiple services (one signs, others verify with public key)
ES256 (ECDSA-SHA256)Asymmetric — faster than RSASame as RS256 but more efficient

For microservices where multiple services need to validate tokens issued by a central auth service, use RS256 — distribute only the public key, keep the private key in the auth service.

// RS256 — load keys from classpath
@Bean
public JwtDecoder jwtDecoder() throws Exception {
    KeyFactory kf = KeyFactory.getInstance("RSA");
    // Load public key from PEM file
    byte[] encoded = Base64.getDecoder().decode(publicKeyPem);
    X509EncodedKeySpec spec = new X509EncodedKeySpec(encoded);
    RSAPublicKey publicKey = (RSAPublicKey) kf.generatePublic(spec);
    return NimbusJwtDecoder.withPublicKey(publicKey).build();
}

JWT Security Best Practices

mindmap
  root((JWT Security))
    Token Design
      Short expiry for access tokens 15m-1h
      Longer expiry for refresh tokens 7-30d
      Include only necessary claims
      Never include sensitive data in payload
    Signing
      HS256 for single service
      RS256/ES256 for distributed
      Rotate secrets periodically
      256-bit minimum key length
    Transport
      HTTPS always
      Authorization header not URL
      HttpOnly cookie as alternative
    Validation
      Verify signature first
      Check expiration
      Validate issuer and audience
      Check token not in blocklist
    Storage Client-Side
      Memory preferred
      HttpOnly cookie for web apps
      Avoid localStorage XSS risk

Extracting Custom Claims

// Add user ID to token
public String generateToken(AppUserDetails user) {
    return Jwts.builder()
        .subject(user.getUsername())
        .claim("userId", user.getId())
        .claim("email", user.getEmail())
        .claim("roles", user.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority).toList())
        .issuedAt(new Date())
        .expiration(new Date(System.currentTimeMillis() + jwtExpiration))
        .signWith(getSigningKey())
        .compact();
}

// Extract user ID from token without database call
public Long extractUserId(String token) {
    return extractClaim(token, claims -> claims.get("userId", Long.class));
}

Summary

  • JWT is a signed, self-contained token — the server verifies the signature without a database call per request.
  • Structure: header.payload.signature — payload contains claims (sub, iat, exp, roles).
  • JwtAuthenticationFilter (OncePerRequestFilter) reads the Bearer token, validates it, and sets the SecurityContext.
  • The login endpoint calls AuthenticationManager.authenticate() and returns a JWT on success.
  • Use HS256 for single services; use RS256/ES256 for distributed systems where multiple services validate tokens.
  • Short-lived access tokens (15 min–1 hour) + refresh tokens (7–30 days) is the standard pattern. Article 9 covers refresh token implementation.

Next: Article 9 covers refresh tokens — implementing token rotation, revocation, and the complete token lifecycle.