DEV Community

Aswani Nayak
Aswani Nayak

Posted on

Building a Full Enterprise-Ready React + Spring Boot Auth Flow: An End-to-End Guide

Introduction

Authentication is one of those things that looks simple in a tutorial and becomes surprisingly complex in production. Between token storage, CSRF protection, refresh flows, and protected routing, there are many places to get it wrong—and getting it wrong has real security consequences.

In two earlier posts, I covered pieces of this puzzle: Enabling CSRF in a JWT-Based React + Spring Boot Application and Storing Personal Information in React: sessionStorage vs Context API. This post ties those threads together into a complete, end-to-end authentication flow you can adapt for enterprise applications.

We'll walk through the full journey: login → token issuance → secure storage → protected routes → token refresh → logout.

Architecture Overview

Before the code, here's the high-level flow:

┌──────────────┐         ┌──────────────────┐
│    React     │         │   Spring Boot    │
│   Frontend   │         │     Backend      │
└──────┬───────┘         └────────┬─────────┘
       │  1. POST /login          │
       │─────────────────────────>│
       │                          │  validate credentials
       │  2. JWT (httpOnly cookie)│  issue access + refresh
       │<─────────────────────────│
       │                          │
       │  3. GET /protected       │
       │  (+ CSRF token)          │
       │─────────────────────────>│  validate JWT + CSRF
       │  4. Protected data       │
       │<─────────────────────────│
       │                          │
       │  5. POST /refresh        │
       │─────────────────────────>│  rotate tokens
       │                          │
       │  6. POST /logout         │
       │─────────────────────────>│  invalidate session
Enter fullscreen mode Exit fullscreen mode

Key Design Decisions

Decision Choice Rationale
Token storage httpOnly cookies Not accessible to JavaScript → mitigates XSS token theft
CSRF protection Double-submit / token pattern Required when using cookies
Token type Short-lived access + refresh Limits exposure window
State management Context API for auth status Centralized, lightweight

Why httpOnly cookies over localStorage? As I discussed in the storage blog, localStorage is readable by any script on the page—making it vulnerable to XSS. httpOnly cookies trade that risk for the need to handle CSRF, which we address below.

Step 1: Backend — Login and Token Issuance

On successful authentication, the backend issues a JWT and sets it as an httpOnly, secure cookie rather than returning it in the response body.

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request,
                               HttpServletResponse response) {
    // Authenticate credentials (delegated to AuthenticationManager)
    Authentication auth = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(
            request.getUsername(), request.getPassword()));

    String accessToken = jwtService.generateAccessToken(auth);
    String refreshToken = jwtService.generateRefreshToken(auth);

    // Set access token as httpOnly cookie
    ResponseCookie accessCookie = ResponseCookie.from("access_token", accessToken)
        .httpOnly(true)
        .secure(true)
        .path("/")
        .sameSite("Strict")
        .maxAge(Duration.ofMinutes(15))
        .build();

    response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString());
    // Refresh token typically set on a scoped path, e.g. /api/auth/refresh

    return ResponseEntity.ok(new LoginResponse("Login successful"));
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • httpOnly(true) prevents JavaScript access.
  • secure(true) ensures the cookie is only sent over HTTPS.
  • sameSite("Strict") adds a layer of CSRF defense (though we won't rely on it alone).

Step 2: Backend — CSRF Protection

Because we're using cookies, we need CSRF protection. Spring Security supports the double-submit cookie pattern via CookieCsrfTokenRepository.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll()
            .anyRequest().authenticated())
        .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

    return http.build();
}
Enter fullscreen mode Exit fullscreen mode

The CSRF token cookie is intentionally not httpOnly, because the frontend must read it and echo it back in a request header. The attacker's site can't read your cookies cross-origin, so this remains safe.

Step 3: Frontend — Configuring the HTTP Client

Configure your HTTP client to send cookies and include the CSRF token on state-changing requests.

import axios from "axios";

const api = axios.create({
  baseURL: "/api",
  withCredentials: true, // send cookies with every request
});

// Attach CSRF token from cookie to outgoing requests
api.interceptors.request.use((config) => {
  const csrfToken = getCookie("XSRF-TOKEN");
  if (csrfToken) {
    config.headers["X-XSRF-TOKEN"] = csrfToken;
  }
  return config;
});

function getCookie(name) {
  const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
  return match ? decodeURIComponent(match[2]) : null;
}

export default api;
Enter fullscreen mode Exit fullscreen mode

Step 4: Frontend — Auth State with Context API

Since the JWT lives in an httpOnly cookie (invisible to JS), we track authentication status—not the token itself—in React Context.

import { createContext, useContext, useState, useEffect } from "react";
import api from "./api";

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  // On mount, check if an existing session is valid
  useEffect(() => {
    api.get("/auth/me")
      .then((res) => setUser(res.data))
      .catch(() => setUser(null))
      .finally(() => setLoading(false));
  }, []);

  const login = async (credentials) => {
    await api.post("/auth/login", credentials);
    const res = await api.get("/auth/me");
    setUser(res.data);
  };

  const logout = async () => {
    await api.post("/auth/logout");
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, loading, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => useContext(AuthContext);
Enter fullscreen mode Exit fullscreen mode

Design note: We don't store the token in Context or state—only the user's authenticated status. The browser handles the cookie automatically. This is the safest pattern.

Step 5: Frontend — Protecting Routes

Wrap protected routes so unauthenticated users are redirected.

import { Navigate } from "react-router-dom";
import { useAuth } from "./AuthContext";

export function ProtectedRoute({ children }) {
  const { user, loading } = useAuth();

  if (loading) return <div>Loading...</div>;
  if (!user) return <Navigate to="/login" replace />;

  return children;
}
Enter fullscreen mode Exit fullscreen mode

Usage:

<Route
  path="/dashboard"
  element={
    <ProtectedRoute>
      <Dashboard />
    </ProtectedRoute>
  }
/>
Enter fullscreen mode Exit fullscreen mode

Step 6: Handling Token Refresh

Short-lived access tokens improve security but require a refresh mechanism. A response interceptor can transparently retry after refreshing.

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const original = error.config;

    if (error.response?.status === 401 && !original._retry) {
      original._retry = true;
      try {
        await api.post("/auth/refresh"); // rotates cookies server-side
        return api(original);            // retry original request
      } catch (refreshError) {
        // Refresh failed → force logout
        window.location.href = "/login";
        return Promise.reject(refreshError);
      }
    }
    return Promise.reject(error);
  }
);
Enter fullscreen mode Exit fullscreen mode

Security tip: Implement refresh token rotation on the backend—issue a new refresh token on each use and invalidate the old one. This limits damage if a refresh token is ever compromised.

Step 7: Logout

Logout must clear cookies server-side, since the frontend can't touch httpOnly cookies.

@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletResponse response) {
    ResponseCookie clearedCookie = ResponseCookie.from("access_token", "")
        .httpOnly(true)
        .secure(true)
        .path("/")
        .maxAge(0) // expire immediately
        .build();

    response.addHeader(HttpHeaders.SET_COOKIE, clearedCookie.toString());
    // Also invalidate the refresh token in your store
    return ResponseEntity.ok(new LogoutResponse("Logged out"));
}
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls to Avoid

Pitfall Consequence Fix
CORS misconfiguration Requests blocked or insecure Configure allowed origins explicitly
Forgetting withCredentials Cookies not sent Set on HTTP client
Long-lived access tokens Large exposure window Short access + refresh tokens
Not rotating refresh tokens Replay attacks Rotate on every refresh
Skipping CSRF with cookie auth CSRF vulnerability Enable CSRF protection
Storing JWT in localStorage XSS token theft Use httpOnly cookies

Bringing It Together

A production-grade auth flow isn't a single feature—it's a coordinated system:

  • Login issues tokens as secure, httpOnly cookies.
  • CSRF protection guards cookie-based requests.
  • Context API tracks authentication status (never the token).
  • Protected routes gate access on the frontend.
  • Token refresh keeps sessions alive without long-lived tokens.
  • Logout clears state on both ends.

Each piece reinforces the others. Skip one, and you introduce a gap.

A Note on Verification

The code samples here are illustrative and follow common, widely-recommended patterns. Spring Security and React library APIs evolve, so:

  • Verify the exact API for your versions (e.g., Spring Security's CSRF configuration has changed across major releases).
  • Adapt cookie attributes (SameSite, domain, path) to your deployment topology.
  • Always test the full flow against your own security requirements.

This post completes the authentication series that began with the CSRF and storage articles. If there's a specific piece you'd like me to expand—refresh token rotation, CORS configuration, or role-based access—let me know.

Top comments (0)