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

  1. Access Token (15 Minutes): Passed in the Authorization: Bearer <token> header on every HTTP request.
  2. Refresh Token (7 Days): Stored securely and sent only to /api/auth/refresh when an access token expires.
  3. Silent Renewal: The React client catches 401 Unauthorized responses 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.