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 + HttpOnly cookies 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
  • JwtService generates and validates tokens using the JJWT library
  • JwtAuthenticationFilter extracts 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.