JWT Authentication in Spring Boot: A Complete Guide for Java Backend Engineers
Stateless authentication is the backbone of modern REST APIs. Unlike session-based auth — where the server stores session state — JWT (JSON Web Token) lets you embed all the claims directly in the token itself. Scale to a million users? No session store required. Deploy across regions? Tokens validate anywhere. This post walks through building production-grade JWT authentication in Spring Boot — token generation, validation, refresh flows, and the gotchas that will bite you if you're not careful.
How JWT Works
A JWT has three parts: Header, Payload, and Signature. The header identifies the algorithm (HS256, RS256). The payload holds the claims — subject, expiry, roles. The signature proves the token hasn't been tampered with.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIiwicm9sZXMiOlsiQURNSU4iXSwiZXhwIjoxNzQ5MDAwMDAwfQ.abc123signature
In Spring Security, we intercept incoming requests, validate the token, and populate the SecurityContext — all without touching a session store.
Project Setup
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<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>
Add these to application.yml:
jwt:
secret: ${JWT_SECRET} # Minimum 256-bit key for HS256
expiration-ms: 86400000 # 24 hours
refresh-expiration-ms: 604800000 # 7 days
Never commit secrets. Use environment variables in production. For a team like Orglance Technologies, this means injecting
JWT_SECRETvia Docker secrets or a vault in staging and production environments.
Token Service
@Service
public class JwtService {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.expiration-ms}")
private long accessTokenExpiration;
@Value("${jwt.refresh-expiration-ms}")
private long refreshTokenExpiration;
public String generateAccessToken(UserDetails user) {
return buildToken(user, accessTokenExpiration);
}
public String generateRefreshToken(UserDetails user) {
return buildToken(user, refreshTokenExpiration);
}
private String buildToken(UserDetails user, long expiration) {
return Jwts.builder()
.subject(user.getUsername())
.claim("roles", user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes()), Jwts.SIG.HS256)
.compact();
}
public String extractUsername(String token) {
return extractClaims(token).getSubject();
}
public boolean isTokenValid(String token, UserDetails user) {
final String username = extractUsername(token);
return username.equals(user.getUsername()) && !isTokenExpired(token);
}
private Claims extractClaims(String token) {
return Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(secretKey.getBytes()))
.build()
.parseSignedClaims(token)
.getPayload();
}
private boolean isTokenExpired(String token) {
return extractClaims(token).getExpiration().before(new Date());
}
}
JWT Filter
The filter sits between the UsernamePasswordAuthenticationFilter and the SecurityFilterChain. On every request, it extracts the Authorization: Bearer <token> header and validates it.
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String token = authHeader.substring(7);
try {
final String username = jwtService.extractUsername(token);
final UserDetails userDetails =
userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token, userDetails)) {
UsernameAuthenticationToken authToken =
new UsernameAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities());
authToken.setDetails(
new AuthenticationDetailsSource()
.buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
} catch (JwtException e) {
// Token is invalid — don't set authentication, let security config decide
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
}
Security Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
UserDetailsService userDetailsService) {
this.jwtAuthFilter = jwtAuthFilter;
this.userDetailsService = userDetailsService;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
SessionCreationPolicy.STATELESS is critical — it tells Spring Security not to create HTTP sessions.
Authentication Controller
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
public AuthController(JwtService jwtService,
AuthenticationManager authenticationManager) {
this.jwtService = jwtService;
this.authenticationManager = authenticationManager;
}
@PostMapping("/login")
public AuthResponse login(@RequestBody LoginRequest request) {
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.email(), request.password()));
UserDetails user = (UserDetails) auth.getPrincipal();
String accessToken = jwtService.generateAccessToken(user);
String refreshToken = jwtService.generateRefreshToken(user);
return new AuthResponse(accessToken, refreshToken);
}
@PostMapping("/refresh")
public AuthResponse refresh(@RequestBody RefreshRequest request) {
// Validate refresh token, issue new access token
// In production: store refresh tokens in Redis with TTL
// to enable revocation on logout
Claims claims = jwtService.extractClaims(request.refreshToken());
String username = claims.getSubject();
UserDetails user = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(request.refreshToken(), user)) {
String accessToken = jwtService.generateAccessToken(user);
return new AuthResponse(accessToken, request.refreshToken());
}
throw new RuntimeException("Invalid refresh token");
}
}
Token Refresh: What Most Tutorials Get Wrong
The common mistake: issuing a new refresh token on every refresh call. This creates an unbounded token family that's impossible to revoke. In production systems handling sensitive data, do this instead:
- Store issued refresh tokens in Redis with TTL matching the token's expiry.
- On logout: delete the refresh token from Redis.
- On refresh: validate the token exists in Redis before issuing a new access token.
- Use a rotation strategy: consume the old refresh token and issue a new one (prevents replay attacks).
At Orglance Technologies, we implement this pattern for healthcare and fintech clients where token lifecycle management is a compliance requirement — HMIS systems handling patient data and payment APIs both need revocable sessions.
Docker Deployment: The Secret Management Problem
In Dockerfile:
FROM eclipse-temurin:21-jdk-alpine
COPY target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
In docker-compose.yml:
services:
api:
image: your-api
environment:
JWT_SECRET: ${JWT_SECRET} # Injected at runtime, never baked in
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/app
secrets:
- jwt_secret
restart: unless-stopped
secrets:
jwt_secret:
file: ./secrets/jwt_secret.txt
For Kubernetes, use kubernetes.io/tls for secrets or HashiCorp Vault for enterprise deployments. Never use a hardcoded secret across environments.
Common JWT Gotchas
Algorithm confusion attack: Always specify the expected algorithm explicitly. Never accept none or switch algorithms based on the token header.
// WRONG — accepts algorithm from token header
.parser().verifyWith(key).build()
// RIGHT — specifies algorithm, ignores token header
.parser()
.verifyWith(Keys.hmacShaKeyFor(secretKey.getBytes()))
.build()
Long-lived tokens: 24-hour access tokens are reasonable for most apps. If you need longer, implement refresh rotation. Never issue 30-day access tokens without revocation capability.
Logging sensitive data: Never log the raw JWT in production — tokens contain user identifiers and can be used for correlation attacks. Log the jti (JWT ID) claim instead.
Conclusion
JWT authentication in Spring Boot is straightforward once you understand the three moving parts: the token service that builds tokens, the filter that validates incoming tokens, and the security config that wires them together. The gotchas — secret management, token revocation, algorithm validation — are where production systems diverge from tutorials.
For teams building enterprise systems, this pattern scales cleanly. At Orglance Technologies, we've implemented this across healthcare HMIS platforms, fintech payment APIs, and multi-tenant SaaS products. The stateless model means no session affinity, easy horizontal scaling, and clean integration with API gateways.
Next in this series: OAuth2 and OpenID Connect with Spring Security — taking JWT-based auth into the federated identity world.
Author: Harikrushna V is the founder of Orglance Technologies and SnowCare Health Tech, building enterprise Java systems and healthcare IT infrastructure. Connect on LinkedIn.