Securing React and Spring Boot with JWT & Refresh Tokens (Spring Security 6)
Securing React and Spring Boot with JWT & Refresh Tokens (Spring Security 6)
In our previous post, we walked through
When I first integrated JWTs into full-stack applications, I used a single token with a 24-hour expiration. It worked fine in early local testing, but it quickly exposed real architectural risks:
If that token was ever compromised, an attacker had a 24-hour open window with zero server-side revocation control.
Shortening the lifespan to 15 minutes solved the vulnerability, but suddenly users were being abruptly kicked out to the login screen mid-workflow whenever their token expired.
The industry-standard solution is a dual-token lifecycle: short-lived Access Tokens paired with longer-lived Refresh Tokens and automated silent interceptors.
In this guide, I will share the exact setup I use to implement JWT authentication and silent token rotation using Spring Boot 3 (Spring Security 6) and React with Axios Interceptors.
1. Dual-Token Architecture: How It Actually Works
Here is the operational breakdown:
Access Token (15 Minutes): Passed in the
Authorization: Bearer <token>header on every HTTP request.Refresh Token (7 Days): Stored securely and only sent to
/api/auth/refreshwhen the access token expires.Silent Renewal: The React client automatically intercepts expired token errors (
401 Unauthorized), gets a new access token behind the scenes, and transparently retries the failed request before the user even notices.
[ React Client (Axios) ] [ Spring Boot 3 Backend ]
| |
| ----- POST /api/auth/login -------------------> | (Verifies credentials)
| <---- 200 OK { accessToken, refreshToken } ---- |
| |
| ----- GET /api/dashboard (Bearer AccessToken)-> | (Validates signature & expiration)
| <---- 200 OK { protected data } --------------- |
| |
(15 minutes pass: 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 replays the original failed call) |
| ----- GET /api/dashboard (New AccessToken) ---> |
| <---- 200 OK { protected data } --------------- |
2. Spring Boot 3 Backend Setup
Maven Dependencies (pom.xml)
In Spring Boot 3, ensure you import the standard spring-boot-starter-security alongside the jjwt library (0.12.x series):
<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) -->
<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: The JWT Service (JwtService.java)
This service encapsulates token creation, claims extraction, and signature verification using the HMAC-SHA256 algorithm:
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 {
// Gotcha: Key must be at least 256 bits (32 bytes) long for HS256!
// In production, load this from environment variables or application.properties.
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 OncePerRequest Filter (JwtAuthenticationFilter.java)
Every incoming HTTP call passes through this filter before hitting the controller. It inspects the Authorization header, strips the Bearer prefix, and verifies the identity:
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 no Bearer token is present, pass the request along to the next filter
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) {
// Expired or malformed token: continue without setting security context
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: Spring Security 6 Config (SecurityConfig.java)
In Spring Boot 3, SecurityFilterChain uses clean lambda DSL configuration. Notice the two critical elements:
Stateless Sessions:
SessionCreationPolicy.STATELESSensures Spring Security doesn't hold HTTP sessions in memory.Explicit CORS configuration: Essential to allow React (running on
localhost:3000) to send theAuthorizationheader.
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()) // Stateless APIs do not require CSRF
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // Auth endpoints remain public
.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")); // React local development server
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: Authentication Controller (AuthController.java)
Expose the endpoints for user login and token refresh:
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
On the React side, the goal is to make authentication completely invisible to the user after they log in.
The Axios Interceptor (src/api/axios.js)
This configured Axios instance does two crucial jobs:
Automatically attaches the
Authorization: Bearer <token>header to all outgoing requests.Detects
401 Unauthorizedresponses, calls/api/auth/refresh, updates local storage with the new token, and replays the original failed call.
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:8080/api',
});
// 1. Request Interceptor: Attach Access Token
api.interceptors.request.use(
(config) => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 2. Response Interceptor: Silent Refresh on 401
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Check if error is 401 and prevent infinite retry loops
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');
}
// Request a new access token from the backend
const res = await axios.post('http://localhost:8080/api/auth/refresh', {
refreshToken,
});
const newAccessToken = res.data.accessToken;
localStorage.setItem('accessToken', newAccessToken);
// Update the header on the failed request and retry it immediately
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
return api(originalRequest);
} catch (refreshError) {
// Refresh token itself has expired: log out and redirect
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default api;
Protecting Routes in React Router v6 (src/components/ProtectedRoute.jsx)
Wrap your private views with a route guard component:
import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
export default function ProtectedRoute() {
const token = localStorage.getItem('accessToken');
// If token exists, render child routes; otherwise redirect to /login
return token ? <Outlet /> : <Navigate to="/login" replace />;
}
Usage in App.jsx:
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import ProtectedRoute from './components/ProtectedRoute';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
{/* Protected Application Routes */}
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<Dashboard />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;
4.Critical Full-Stack Lessons Learned
Avoid Infinite Loops on 401: In the Axios response interceptor, always set
originalRequest._retry = true. Without this flag, if your refresh endpoint itself returns a 401 or the re-attempted request fails, your frontend will trigger an infinite loop of refresh requests.Keep the Secret Out of Source Control: Always store your secret key in an environment variable or safe vault (
System.getenv(...)). Hardcoding keys in production repositories is a serious security risk.CORS Headers on Unauthorized Responses: Ensure your Spring Boot CORS configuration explicitly applies to all paths (
/**). If Spring Security blocks an unauthenticated request before CORS is applied, React won't even receive the401status code—the browser will simply throw a generic CORS error.
Next Steps
By combining short-lived access tokens with automated silent refresh interceptors, you get high-level API security without sacrificing user experience.
In our upcoming post, we will tackle Containerizing React, Spring Boot, and PostgreSQL with Docker & Docker Compose to package our entire full-stack stack into a single, production-ready deployment command.

Comments
Post a Comment