In modern full-stack development, securing communication between a Single Page Application (SPA) and a REST API is a fundamental requirement. While stateless JSON Web Tokens (JWTs) are popular, relying on a single, long-lived token introduces a major vulnerability: if intercepted, an attacker retains access until expiration with no immediate revocation mechanism.
Shortening token validity to 15 minutes resolves this exposure, but causes abrupt logouts unless automated.
The standard architectural pattern is a dual-token lifecycle: short-lived Access Tokens paired with longer-lived Refresh Tokens and automated silent interceptors.
In this tutorial, we will build a production-grade dual-token authentication system using Spring Boot 3 (Spring Security 6) and React with Axios Interceptors.
1. Dual-Token Architecture Overview
-
Access Token (15 Minutes): Passed in the
Authorization: Bearer <token>header on every HTTP request. -
Refresh Token (7 Days): Stored securely and sent only to
/api/auth/refreshwhen an access token expires. -
Silent Renewal: The React client catches
401 Unauthorizedresponses via an Axios response interceptor, fetches a fresh access token, and automatically retries the initial request seamlessly.
text
[ React Client (Axios) ] [ Spring Boot 3 Backend ]
| |
| ----- POST /api/auth/login -------------------> | (Authenticates credentials)
| <---- 200 OK { accessToken, refreshToken } ---- |
| |
| ----- GET /api/dashboard (Bearer AccessToken)-> | (Validates signature & expiry)
| <---- 200 OK { protected data } --------------- |
| |
(15 min passes: Access Token expires) |
| |
| ----- GET /api/dashboard (Expired Token) -----> |
| <---- 401 Unauthorized ------------------------ |
| |
(Axios Interceptor catches 401) |
| ----- POST /api/auth/refresh (RefreshToken) --> | (Validates refresh token)
| <---- 200 OK { accessToken: newAccessToken } -- |
| |
(Interceptor retries original request) |
| ----- GET /api/dashboard (New AccessToken) ---> |
| <---- 200 OK { protected data } --------------- |
2. Spring Boot 3 Backend Setup
Maven Dependencies (pom.xml)
XML
<dependencies>
<!-- Spring Security & Web -->
<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>
<!-- Java JWT (JJWT) 0.12.x -->
<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>
</dependencies>
Step 1: JWT Service (JwtService.java)
Manages signing, claims extraction, and expiration validation:
Java
package com.example.demo.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Service
public class JwtService {
// 256-bit secret key (Keep this secure in environment variables in production)
private static final String SECRET = "404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970";
private static final long ACCESS_TOKEN_EXPIRATION = 15 * 60 * 1000; // 15 minutes
private static final long REFRESH_TOKEN_EXPIRATION = 7 * 24 * 60 * 60 * 1000; // 7 days
private SecretKey getSignInKey() {
return Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));
}
public String generateAccessToken(UserDetails userDetails) {
return buildToken(new HashMap<>(), userDetails.getUsername(), ACCESS_TOKEN_EXPIRATION);
}
public String generateRefreshToken(UserDetails userDetails) {
return buildToken(new HashMap<>(), userDetails.getUsername(), REFRESH_TOKEN_EXPIRATION);
}
private String buildToken(Map<String, Object> extraClaims, String subject, long expirationMs) {
return Jwts.builder()
.claims(extraClaims)
.subject(subject)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(getSignInKey())
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
private boolean isTokenExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = Jwts.parser()
.verifyWith(getSignInKey())
.build()
.parseSignedClaims(token)
.getPayload();
return claimsResolver.apply(claims);
}
}
Step 2: The Authentication Filter (JwtAuthenticationFilter.java)
Intercepts incoming requests and validates the bearer token before Spring Security authorizes the request:
Java
package com.example.demo.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@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(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7);
final String userEmail;
try {
userEmail = jwtService.extractUsername(jwt);
} catch (Exception e) {
filterChain.doFilter(request, response);
return;
}
if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
Step 3: Security Configuration (SecurityConfig.java)
Java
package com.example.demo.config;
import com.example.demo.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter) {
this.jwtAuthFilter = jwtAuthFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
Step 4: Auth Controller (AuthController.java)
Java
package com.example.demo.controller;
import com.example.demo.security.JwtService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final UserDetailsService userDetailsService;
private final JwtService jwtService;
public AuthController(AuthenticationManager authenticationManager,
UserDetailsService userDetailsService,
JwtService jwtService) {
this.authenticationManager = authenticationManager;
this.userDetailsService = userDetailsService;
this.jwtService = jwtService;
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody Map<String, String> request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.get("username"), request.get("password"))
);
UserDetails user = userDetailsService.loadUserByUsername(request.get("username"));
String accessToken = jwtService.generateAccessToken(user);
String refreshToken = jwtService.generateRefreshToken(user);
return ResponseEntity.ok(Map.of(
"accessToken", accessToken,
"refreshToken", refreshToken
));
}
@PostMapping("/refresh")
public ResponseEntity<?> refreshToken(@RequestBody Map<String, String> request) {
String refreshToken = request.get("refreshToken");
String username = jwtService.extractUsername(refreshToken);
UserDetails user = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(refreshToken, user)) {
String newAccessToken = jwtService.generateAccessToken(user);
return ResponseEntity.ok(Map.of("accessToken", newAccessToken));
}
return ResponseEntity.status(401).body(Map.of("message", "Invalid or expired refresh token"));
}
}
3. React Frontend Integration
The Axios Silent Refresh Interceptor (src/api/axios.js)
JavaScript
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:8080/api',
});
// Attach access token to outgoing requests
api.interceptors.request.use(
(config) => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Intercept 401 Unauthorized and refresh silently
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token available');
}
const res = await axios.post('http://localhost:8080/api/auth/refresh', {
refreshToken,
});
const newAccessToken = res.data.accessToken;
localStorage.setItem('accessToken', newAccessToken);
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
return api(originalRequest);
} catch (refreshError) {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default api;
Critical Full-Stack Edge Cases
Avoid Infinite Loops on 401: Ensure originalRequest._retry = true is set before invoking the refresh endpoint to prevent recursive request loops.
Environment Variable Storage: Never commit signing keys directly to GitHub. Load secrets via environment variables (System.getenv(...)).
CORS on Security Blocks: Apply CORS configurations globally to avoid browsers masking 401 Unauthorized responses with generic cross-origin exceptions.
Originally published on my blog: Securing React and Spring Boot with JWT & Refresh Tokens.
Top comments (0)