Skip to main content
Java intermediate Lesson 57 of 58

JWT Authentication in Spring Boot

Implement stateless JWT authentication in Spring Boot — generating tokens, validating them in a filter, refresh tokens, and securing endpoints.

JWT (JSON Web Token) is the standard approach for stateless authentication in REST APIs. The server issues a signed token after login; the client attaches it to every subsequent request; the server verifies the signature without consulting a session store. This makes the architecture horizontally scalable — any server instance can verify any token independently.

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>

Configuration

Keep the JWT secret and expiry times in application.properties so they can vary per environment without recompiling. The secret must be at least 256 bits (32 characters) for HMAC-SHA256 — use a randomly generated value, never a human-readable string.

# application.properties
app.jwt.secret=${JWT_SECRET}          # read from environment — never hardcode a secret
app.jwt.expiration=900000             # 15 minutes in milliseconds — short to limit exposure
app.jwt.refresh-expiration=604800000  # 7 days in milliseconds for the refresh token

JWT Service

The JwtService is responsible for generating tokens and extracting claims from them. The signing key is derived from the base64-encoded secret. extractClaim is a generic method that accepts a function, which lets callers extract any claim (subject, expiry, custom fields) with one method.

@Service
public class JwtService {

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

    @Value("${app.jwt.expiration}")
    private long expiration;

    // Decode the base64 secret and create the HMAC signing key
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
    }

    // Generate a token with no extra claims — just the username and expiry
    public String generateToken(UserDetails userDetails) {
        return generateToken(Map.of(), userDetails);
    }

    // Generate a token with additional claims (e.g. roles, token type)
    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return Jwts.builder()
            .claims(extraClaims)
            .subject(userDetails.getUsername())
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + expiration))
            .signWith(getSigningKey())
            .compact();
    }

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

    // Validates the token: username matches AND not expired
    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());
    }

    // Generic claim extractor — parses the token, verifies signature, applies the resolver
    private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        Claims claims = Jwts.parser()
            .verifyWith(getSigningKey())   // verifies the signature; throws if tampered
            .build()
            .parseSignedClaims(token)
            .getPayload();
        return claimsResolver.apply(claims);
    }
}

JWT Authentication Filter

The filter runs on every request. It extracts the token from the Authorization header, validates it, and — if valid — populates the Spring Security context with the user’s identity. Once the context is populated, Spring Security’s authorization rules apply normally. The filter is stateless: it reads no session store and writes no state.

@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtService            jwtService;
    private final UserDetailsService    userDetailsService;

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

        String authHeader = req.getHeader("Authorization");

        // No bearer token — pass through; Spring Security will handle unauthenticated access
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            chain.doFilter(req, res);
            return;
        }

        String token = authHeader.substring(7); // strip "Bearer " prefix
        String username;

        try {
            username = jwtService.extractUsername(token);
        } catch (JwtException e) {
            // Malformed or tampered token — pass through and let Spring Security return 401
            chain.doFilter(req, res);
            return;
        }

        // Authenticate if we have a username and no existing auth in the context
        // (avoid re-authenticating for every filter in the chain)
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            if (jwtService.isTokenValid(token, userDetails)) {
                // Create an authentication token and set it in the security context
                UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        chain.doFilter(req, res); // always continue the filter chain
    }
}

Wire the Filter into Security Config

Register the JWT filter to run before Spring Security’s built-in UsernamePasswordAuthenticationFilter. This ensures the security context is populated from the JWT before any authorization checks run.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthFilter          jwtAuthFilter;
    private final UserDetailsService     userDetailsService;
    private final PasswordEncoder        passwordEncoder;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())          // stateless API — no CSRF needed
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()  // login/register don't need a token
                .anyRequest().authenticated()
            )
            .authenticationProvider(authenticationProvider())
            // JWT filter runs before the username/password filter so the context is set first
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

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

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

Auth Controller

The auth endpoints are public — they don’t require a token. Login authenticates the credentials and returns both an access token and a refresh token. The refresh endpoint issues a new access token without requiring the user to log in again.

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

    private final AuthService authService;

    @PostMapping("/register")
    @ResponseStatus(HttpStatus.CREATED)
    public AuthResponse register(@RequestBody @Valid RegisterRequest req) {
        return authService.register(req);
    }

    @PostMapping("/login")
    public AuthResponse login(@RequestBody @Valid LoginRequest req) {
        return authService.login(req);
    }

    @PostMapping("/refresh")
    public AuthResponse refresh(@RequestBody RefreshRequest req) {
        return authService.refreshToken(req.refreshToken());
    }
}

public record LoginRequest(@NotBlank String email, @NotBlank String password) {}
public record RegisterRequest(@NotBlank String name, @Email String email,
                               @Size(min = 8) String password) {}
public record AuthResponse(String accessToken, String refreshToken, UserResponse user) {}

Auth Service

AuthService delegates credential verification to Spring Security’s AuthenticationManager. If credentials are wrong, authManager.authenticate() throws AuthenticationException and the login fails — no additional logic needed. On success, both tokens are generated and returned.

@Service
@RequiredArgsConstructor
public class AuthService {

    private final UserRepository      userRepository;
    private final PasswordEncoder     passwordEncoder;
    private final JwtService          jwtService;
    private final AuthenticationManager authManager;

    public AuthResponse register(RegisterRequest req) {
        if (userRepository.existsByEmail(req.email())) {
            throw new DuplicateResourceException("Email already registered");
        }
        User user = userRepository.save(new User(
            req.name(), req.email(),
            passwordEncoder.encode(req.password()), UserRole.USER));
        return generateResponse(user);
    }

    public AuthResponse login(LoginRequest req) {
        // Throws AuthenticationException if credentials are invalid — handled by global handler
        authManager.authenticate(
            new UsernamePasswordAuthenticationToken(req.email(), req.password()));

        User user = userRepository.findByEmail(req.email())
            .orElseThrow(() -> new ResourceNotFoundException("User", req.email()));
        return generateResponse(user);
    }

    private AuthResponse generateResponse(User user) {
        UserDetails userDetails = org.springframework.security.core.userdetails.User
            .withUsername(user.getEmail())
            .password(user.getPasswordHash())
            .roles(user.getRole().name())
            .build();

        String accessToken  = jwtService.generateToken(userDetails);
        // Refresh token carries a "type" claim so it can be distinguished from access tokens
        String refreshToken = jwtService.generateToken(Map.of("type", "refresh"), userDetails);

        return new AuthResponse(accessToken, refreshToken, UserResponse.from(user));
    }
}

Using the API

# Register a new user
curl -X POST /api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","email":"alice@example.com","password":"Secret123!"}'

# Login — returns access and refresh tokens
curl -X POST /api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"Secret123!"}'
# {"accessToken":"eyJ...","refreshToken":"eyJ...","user":{...}}

# Call a protected endpoint — attach the access token in the Authorization header
curl /api/users/me \
  -H "Authorization: Bearer eyJ..."

Frequently Asked Questions

What is inside a JWT?
A JWT has three Base64-encoded parts separated by dots: header (algorithm), payload (claims — user ID, email, roles, expiry), and signature (HMAC or RSA). The server verifies the signature to confirm the token wasn't tampered with. The payload is readable by anyone — never store secrets in a JWT.
Where should I store JWTs on the client side?
HttpOnly cookies are the most secure option — JavaScript cannot read them, preventing XSS theft. localStorage is convenient but readable by any JavaScript on the page. sessionStorage is similar to localStorage but cleared when the tab closes. For SPAs the common pragmatic choice is localStorage with short expiry + refresh tokens.
What is a refresh token?
Access tokens are short-lived (15 min) to limit damage if stolen. A refresh token is a long-lived token (7–30 days) stored securely, used only to obtain a new access token when the current one expires. The client silently exchanges the refresh token without the user logging in again.