Securing stateless REST APIs relies heavily on short-lived access tokens paired with longer-lived refresh credentials. This pattern prevents frequent re-authentication while limiting exposure from compromised tokens. Below is a practical implementation of this mechanism using Spring Boot, Spring Security, and the JJWT library.
Core Dependencies
Add the required starters and authentication libraries to your build configuration:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
</dependencies>
Token Management Service
Create a centralized utility to handle cryptographic key generation, token creation, parsing, and validation. Separating these concerns improves maintainability and simplifies testing.
package com.secure.api.jwt;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Component
public class JwtService {
private final SecretKey signingKey;
private final long expirationMs;
public JwtService() {
// In production, load keys from environment variables or a secrets manager
this.signingKey = Keys.hmacShaKeyFor("SecurePrivateKeyStringThatIsLongEnoughForHmacShaxxx".getBytes(StandardCharsets.UTF_8));
this.expirationMs = 1000L * 60 * 60; // 1 hour
}
public String generateToken(String identifier) {
return Jwts.builder()
.setSubject(identifier)
.setIssuedAt(new Date())
.signWith(signingKey, SignatureAlgorithm.HS256)
.compact();
}
public Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(signingKey)
.build()
.parseClaimsJws(token)
.getBody();
}
public boolean isValid(String token) {
try {
parseClaims(token);
return true;
} catch (Exception e) {
return false;
}
}
public long getExpirationMs() { return expirationMs; }
}
Security Configuration & Filter
Intercept incoming HTTP requests, extract the bearer token, and populate the security context upon successful validation.
package com.secure.api.config;
import com.secure.api.jwt.JwtService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtService jwtService;
public SecurityConfig(JwtService jwtService) {
this.jwtService = jwtService;
}
@Bean
public OncePerRequestFilter jwtAuthFilter() {
return new OncePerRequestFilter() {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtService.isValid(token)) {
Claims claims = jwtService.parseClaims(token);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(claims.getSubject(), null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(request, response);
}
};
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
Authentication Endpoint
Handle credential verification and issue an initial access token.
package com.secure.api.controller;
import com.secure.api.dto.AuthRequest;
import com.secure.api.dto.TokenResponse;
import com.secure.api.jwt.JwtService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class LoginController {
private final AuthenticationManager authManager;
private final JwtService jwtService;
public LoginController(AuthenticationManager authManager, JwtService jwtService) {
this.authManager = authManager;
this.jwtService = jwtService;
}
@PostMapping("/api/auth/login")
public ResponseEntity<TokenResponse> authenticate(@RequestBody AuthRequest req) {
authManager.authenticate(new UsernamePasswordAuthenticationToken(req.username(), req.password()));
String accessToken = jwtService.generateToken(req.username());
return ResponseEntity.ok(new TokenResponse(accessToken));
}
}
Refresh Logic Implementation
The core requirement involves validating the existing token, extracting the principal, and issuing a freshly stamped credential. Rather than mutating claim timestamps inline—which can interfere with signature verification—reconstructing the token ensures crytpographic integrity.
package com.secure.api.service;
import com.secure.api.jwt.JwtService;
import org.springframework.stereotype.Service;
@Service
public class TokenRefreshService {
private final JwtService jwtService;
public TokenRefreshService(JwtService jwtService) {
this.jwtService = jwtService;
}
public String executeRefresh(String currentToken) {
var claims = jwtService.parseClaims(currentToken);
String principal = claims.getSubject();
return jwtService.generateToken(principal);
}
}
Refresh Controller & Data Transfer Objects
Expose the renewal endpoint alongside simplified request/response payloads.
package com.secure.api.controller;
import com.secure.api.dto.TokenRequest;
import com.secure.api.dto.TokenResponse;
import com.secure.api.service.TokenRefreshService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class RenewalController {
private final TokenRefreshService refreshService;
public RenewalController(TokenRefreshService refreshService) {
this.refreshService = refreshService;
}
@PostMapping("/api/auth/renew")
public ResponseEntity<TokenResponse> renewToken(@RequestBody TokenRequest request) {
String newToken = refreshService.executeRefresh(request.token());
return ResponseEntity.ok(new TokenResponse(newToken));
}
}
Data transfer objects encapsulate communication contracts:
package com.secure.api.dto;
public record AuthRequest(String username, String password) {}
public record TokenResponse(String accessToken) {}
public record TokenRequest(String token) {}
Integration Flow
Upon client login, the /api/auth/login endpoint returns the initial accessToken. The client stores this locally and includes it in the Authorization header for subsequent requests. When downstream services respond with a 401 Unauthorized, the frontend automatically triggers a call to /api/auth/renew using the stale token. The server validates the signature via JwtService.isValid(), extracts the principal identity, generates a new token with a refreshed expiration window, and returns it. The authentication cycle continues until the refresh credential itself fails validation, at which point the user must re-enter credentials. Production deployments should wrap the validation layer in comprehensive exception handling to manage tampered payloads and misconfigured signatures gracefully.