Spring Boot JWT Authentication: Complete Implementation Guide

JWT (JSON Web Token) authentication is the standard approach for securing stateless REST APIs. Instead of server-side sessions, the client receives a signed token on login and presents it with every subsequent request. This guide walks through a full Spring Boot 3 + Spring Security 6 JWT implementation from scratch.

How JWT Authentication Works

  1. Client sends credentials (username + password) to POST /auth/login
  2. Server validates credentials, generates a JWT signed with a secret key, returns it
  3. Client stores the token (memory or HttpOnly cookie) and sends it as Authorization: Bearer <token> on each request
  4. Server validates the token signature and expiry on every request — no database lookup needed

A JWT has three Base64-encoded parts: header (algorithm), payload (claims — userId, roles, expiry), and signature (HMAC or RSA of header + payload).

Stateless = scalable: Because the server does not store session state, any instance of your application can validate the same token. This is essential for horizontal scaling.

Dependencies

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </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>
</dependencies>

JWT Service — Token Generation & Validation

@Service
public class JwtService {

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

    @Value("${app.jwt.expiration-ms:86400000}")  // 24h default
    private long expirationMs;

    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
    }

    public String generateToken(UserDetails userDetails) {
        return Jwts.builder()
            .subject(userDetails.getUsername())
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + expirationMs))
            .signWith(getSigningKey())
            .compact();
    }

    public String extractUsername(String token) {
        return Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload()
            .getSubject();
    }

    public boolean isTokenValid(String token, UserDetails userDetails) {
        try {
            String username = extractUsername(token);
            return username.equals(userDetails.getUsername()) && !isExpired(token);
        } catch (JwtException e) {
            return false;
        }
    }

    private boolean isExpired(String token) {
        return Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload()
            .getExpiration()
            .before(new Date());
    }
}

Add to application.yml:

app:
  jwt:
    secret: your-256-bit-base64-encoded-secret-key-here
    expiration-ms: 86400000  # 24 hours

JWT Authentication Filter

This filter intercepts every request, extracts the token from the Authorization header, validates it, and sets the security context:

@Component
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    public JwtAuthFilter(JwtService jwtService, UserDetailsService userDetailsService) {
        this.jwtService = jwtService;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }

        String token = authHeader.substring(7);
        String username = jwtService.extractUsername(token);

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            if (jwtService.isTokenValid(token, userDetails)) {
                UsernamePasswordAuthenticationToken auth =
                    new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        }
        chain.doFilter(request, response);
    }
}

Security Configuration

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthFilter jwtAuthFilter;

    public SecurityConfig(JwtAuthFilter jwtAuthFilter) {
        this.jwtAuthFilter = jwtAuthFilter;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }

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

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

Login Endpoint

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

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

    @PostMapping("/login")
    public ResponseEntity<Map<String, String>> login(@RequestBody LoginRequest req) {
        authManager.authenticate(
            new UsernamePasswordAuthenticationToken(req.username(), req.password())
        );
        UserDetails user = userDetailsService.loadUserByUsername(req.username());
        String token = jwtService.generateToken(user);
        return ResponseEntity.ok(Map.of("token", token));
    }
}

public record LoginRequest(String username, String password) {}

Testing the Flow

# Step 1: Login and get token
curl -X POST http://localhost:8080/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"user@example.com","password":"secret"}'

# Response: {"token":"eyJhbGci..."}

# Step 2: Call secured endpoint
curl http://localhost:8080/api/orders \
  -H "Authorization: Bearer eyJhbGci..."

Security Best Practices

  • Use RS256 (asymmetric) in production for microservices — the private key signs tokens, services use the public key to verify. No shared secret needed.
  • Short expiry + refresh tokens: Access tokens should expire in 15–60 minutes. Issue a long-lived refresh token separately (stored server-side) to get new access tokens.
  • Never store JWTs in localStorage — vulnerable to XSS. Use HttpOnly cookies for the access token.
  • Rotate signing keys periodically. Implement JWKS endpoint if using RS256 so clients can fetch the current public key.
  • Validate all claims: Always check exp, iss, and aud — not just the signature.

Conclusion

JWT authentication in Spring Boot 3 involves four components: a JwtService to generate and validate tokens, a JwtAuthFilter to intercept requests, a SecurityConfig to wire them together, and a login endpoint to issue tokens. The pattern is stateless, scales horizontally, and is compatible with any frontend framework. Focus on short-lived access tokens, HttpOnly cookies, and refresh token rotation for a production-grade implementation.

Related: Spring Boot Hub | Java Microservices Architecture