DEV Community

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 โ”‚ โ”‚ validate JWT + CSRF โ”‚ 4. Protected data โ”‚ โ”‚ โ”‚ rotate tokens โ”‚ โ”‚ โ”‚ 6. POST /logout โ”‚ โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ invalidate session 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")); } 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(); } 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; 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 ( {children} ); } export const useAuth = () => useContext(AuthContext); 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 Loading... ; if (!user) return ; return children; } Usage: } /> 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); } ); 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")); } 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)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.