JWT Authentication: Build a Complete Login System
JWT (JSON Web Token) is the standard for stateless REST API authentication. This article builds a complete JWT authentication system — login, token generation, request validation, and token refresh.
What is a JWT?
A JWT has three base64url-encoded parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← Header (algorithm + type)
.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZXMiOlsiVVNFUiJdLCJpYXQiOjE3MTQ3MjY0MDAsImV4cCI6MTcxNDczMDAwMH0 ← Payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature
The payload contains claims:
{
"sub": "user123", // subject (user identifier)
"roles": ["USER"],
"iat": 1714726400, // issued at
"exp": 1714730000 // expires at
}
The signature is a HMAC of the header+payload — tamper-proof. Anyone can read a JWT (it’s just base64), but only the server with the secret key can issue valid ones.
Dependency
<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>
JwtService — Token Generation and Validation
@Service
public class JwtService {
@Value("${security.jwt.secret}")
private String secret;
@Value("${security.jwt.access-token-expiry-ms:900000}") // 15 minutes
private long accessTokenExpiryMs;
@Value("${security.jwt.refresh-token-expiry-ms:604800000}") // 7 days
private long refreshTokenExpiryMs;
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
return Keys.hmacShaKeyFor(keyBytes);
}
// Generate access token
public String generateAccessToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList());
return buildToken(claims, userDetails.getUsername(), accessTokenExpiryMs);
}
// Generate refresh token (longer-lived, minimal claims)
public String generateRefreshToken(UserDetails userDetails) {
return buildToken(Map.of("type", "refresh"),
userDetails.getUsername(), refreshTokenExpiryMs);
}
private String buildToken(Map<String, Object> claims, String subject, long expiryMs) {
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiryMs))
.signWith(getSigningKey())
.compact();
}
// Extract username from token
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
// Extract roles from token
@SuppressWarnings("unchecked")
public List<String> extractRoles(String token) {
return extractClaim(token, claims -> (List<String>) claims.get("roles"));
}
// Validate token
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
public long getAccessTokenExpiryMs() { return accessTokenExpiryMs; }
}
Configure the secret:
security:
jwt:
secret: ${JWT_SECRET} # 256-bit base64-encoded secret from environment
access-token-expiry-ms: 900000 # 15 minutes
refresh-token-expiry-ms: 604800000 # 7 days
Generate a secure secret:
openssl rand -base64 32
# → 8dD+5xn7fEWMdJ4mq6Kfp+sHVqYeFxzXR/BkLNjBMoQ=
JwtAuthenticationFilter
Intercepts every request, extracts the JWT, 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 {
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
// No Bearer token — pass through (security config decides if endpoint is protected)
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7); // strip "Bearer "
try {
String username = jwtService.extractUsername(token);
// Only set authentication if not already set
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token, userDetails)) {
// Create authentication token with roles from JWT claims
List<SimpleGrantedAuthority> authorities = jwtService.extractRoles(token)
.stream()
.map(SimpleGrantedAuthority::new)
.toList();
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, authorities
);
authToken.setDetails(new WebAuthenticationDetailsSource()
.buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (ExpiredJwtException e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("""
{"type":"https://devopsmonk.com/errors/token-expired",
"title":"Token Expired","status":401,
"detail":"JWT token has expired. Please refresh your token."}
""");
return;
} catch (JwtException e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("""
{"type":"https://devopsmonk.com/errors/invalid-token",
"title":"Invalid Token","status":401,
"detail":"JWT token is invalid."}
""");
return;
}
filterChain.doFilter(request, response);
}
}
Register the Filter in SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
.accessDeniedHandler(
(req, res, e) -> res.sendError(SC_FORBIDDEN))
);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(
UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(provider);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}
Refresh Tokens
Access tokens should be short-lived (15 minutes). Refresh tokens are longer-lived (7 days) and stored server-side (so they can be revoked):
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false, unique = true)
private String token;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
private boolean revoked = false;
public boolean isExpired() { return Instant.now().isAfter(expiresAt); }
public boolean isValid() { return !revoked && !isExpired(); }
}
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final RefreshTokenRepository tokenRepository;
private final UserRepository userRepository;
private final JwtService jwtService;
@Value("${security.jwt.refresh-token-expiry-ms}")
private long refreshTokenExpiryMs;
@Transactional
public RefreshToken createRefreshToken(String username) {
User user = userRepository.findByUsername(username).orElseThrow();
// Revoke existing tokens for this user
tokenRepository.revokeAllByUser(user);
RefreshToken token = new RefreshToken();
token.setToken(UUID.randomUUID().toString());
token.setUser(user);
token.setExpiresAt(Instant.now().plusMillis(refreshTokenExpiryMs));
return tokenRepository.save(token);
}
@Transactional
public TokenPair refreshAccessToken(String refreshTokenValue) {
RefreshToken refreshToken = tokenRepository.findByToken(refreshTokenValue)
.orElseThrow(() -> new InvalidTokenException("Refresh token not found"));
if (!refreshToken.isValid()) {
throw new InvalidTokenException(
refreshToken.isRevoked() ? "Refresh token revoked" : "Refresh token expired"
);
}
UserDetails userDetails = refreshToken.getUser();
String newAccessToken = jwtService.generateAccessToken(userDetails);
// Rotate refresh token (invalidate old, issue new)
refreshToken.setRevoked(true);
RefreshToken newRefreshToken = createRefreshToken(userDetails.getUsername());
return new TokenPair(newAccessToken, newRefreshToken.getToken());
}
@Transactional
public void revokeRefreshToken(String tokenValue) {
tokenRepository.findByToken(tokenValue)
.ifPresent(token -> {
token.setRevoked(true);
tokenRepository.save(token);
});
}
}
Complete Auth Endpoints
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationService authService;
private final RefreshTokenService refreshTokenService;
private final UserRegistrationService registrationService;
@PostMapping("/register")
public ResponseEntity<UserResponse> register(@RequestBody @Valid RegisterRequest req) {
User user = registrationService.register(req);
return ResponseEntity.created(URI.create("/api/users/" + user.getId()))
.body(UserResponse.from(user));
}
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody @Valid LoginRequest req) {
UserDetails user = authService.authenticate(req.username(), req.password());
String accessToken = jwtService.generateAccessToken(user);
RefreshToken refreshToken = refreshTokenService.createRefreshToken(user.getUsername());
return ResponseEntity.ok(new LoginResponse(
accessToken, refreshToken.getToken(), "Bearer",
jwtService.getAccessTokenExpiryMs() / 1000
));
}
@PostMapping("/refresh")
public ResponseEntity<TokenPair> refresh(@RequestBody RefreshRequest req) {
TokenPair tokens = refreshTokenService.refreshAccessToken(req.refreshToken());
return ResponseEntity.ok(tokens);
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestBody LogoutRequest req) {
refreshTokenService.revokeRefreshToken(req.refreshToken());
return ResponseEntity.noContent().build();
}
}
Testing JWT Authentication
@SpringBootTest
@AutoConfigureMockMvc
class JwtAuthTest {
@Autowired MockMvc mockMvc;
@Autowired JwtService jwtService;
@Autowired UserRepository userRepository;
@Autowired PasswordEncoder passwordEncoder;
@Autowired ObjectMapper mapper;
@BeforeEach
void setup() {
User user = new User();
user.setUsername("testuser");
user.setEmail("test@example.com");
user.setPassword(passwordEncoder.encode("Password1!"));
user.setRoles(Set.of(Role.USER));
userRepository.save(user);
}
@Test
void shouldReturnTokenOnLogin() throws Exception {
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(
new LoginRequest("testuser", "Password1!")
)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.accessToken").isNotEmpty());
}
@Test
void shouldAccessProtectedEndpointWithValidToken() throws Exception {
UserDetails user = userRepository.findByUsername("testuser").orElseThrow();
String token = jwtService.generateAccessToken(user);
mockMvc.perform(get("/api/orders")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
}
@Test
void shouldReturn401WithoutToken() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
@Test
void shouldReturn401WithExpiredToken() throws Exception {
// Generate a token that expired 1 hour ago
String expiredToken = Jwts.builder()
.subject("testuser")
.expiration(new Date(System.currentTimeMillis() - 3600000))
.signWith(/* key */)
.compact();
mockMvc.perform(get("/api/orders")
.header("Authorization", "Bearer " + expiredToken))
.andExpect(status().isUnauthorized());
}
}
Security Best Practices Summary
- Short access token TTL (15 min) — limits damage if token is leaked
- Rotate refresh tokens — each refresh issues a new refresh token, invalidates the old one
- Store refresh tokens server-side — so they can be revoked (unlike JWTs which can’t be invalidated)
- Use RS256 (asymmetric) for multi-service JWT — services can verify without the secret
- Never put sensitive data in the JWT payload — it’s base64, not encrypted
- Use HTTPS — JWTs in transit are just strings; TLS is mandatory
- Set
Secure+HttpOnlycookies for browser clients storing tokens (prevents XSS theft)
What You’ve Learned
- A JWT is a signed payload — readable by anyone, tamper-proof without the secret key
JwtServicegenerates and validates tokens using the JJWT libraryJwtAuthenticationFilterextracts and validates JWTs on every request- Short-lived access tokens + long-lived refresh tokens is the standard pattern
- Store refresh tokens in the database — they can be revoked; pure JWTs cannot
- Rotate refresh tokens on every use — invalidates stolen tokens
Next: Article 26 — Role-Based Access Control with @PreAuthorize — fine-grained method-level security.