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
| Algorithm | Key type | Use when |
|---|---|---|
| HS256 (HMAC-SHA256) | Symmetric — one shared secret | Single service (same app signs and verifies) |
| RS256 (RSA-SHA256) | Asymmetric — private key signs, public key verifies | Multiple services (one signs, others verify with public key) |
| ES256 (ECDSA-SHA256) | Asymmetric — faster than RSA | Same 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 theBearertoken, validates it, and sets theSecurityContext.- 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.