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 connecting React Hook Form to a Spring Boot REST API with DTO validation and global exception handling. Once an API reliably accepts and validates incoming payloads, the very next challenge every full-stack developer faces is: How do we securely authenticate requests without breaking the user experience?

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:

  1. If that token was ever compromised, an attacker had a 24-hour open window with zero server-side revocation control.

  2. 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:

  1. Access Token (15 Minutes): Passed in the Authorization: Bearer <token> header on every HTTP request.

  2. Refresh Token (7 Days): Stored securely and only sent to /api/auth/refresh when the access token expires.

  3. 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):

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) -->
    <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:

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 {

    // 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:

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 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:

  1. Stateless Sessions: SessionCreationPolicy.STATELESS ensures Spring Security doesn't hold HTTP sessions in memory.

  2. Explicit CORS configuration: Essential to allow React (running on localhost:3000) to send the Authorization header.

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()) // 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:

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

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:

  1. Automatically attaches the Authorization: Bearer <token> header to all outgoing requests.

  2. Detects 401 Unauthorized responses, calls /api/auth/refresh, updates local storage with the new token, and replays the original failed call.

JavaScript
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:

JavaScript
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:

JavaScript
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

  1. 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.

  2. 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.

  3. 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 the 401 status 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

Popular posts from this blog

React Basics – How to Create Component

How to Build REST API using Spring Boot (Step-by-Step Guide)

Spring Boot @RequestParam Annotation Explained with Real-Time Examples