React Authentication
August 31, 2025
Overview
React Authentication is the set of patterns and tools you use to identify users, keep them signed in, and gate access to UI and APIs. Done well, auth is invisible to users and simple for developers; done poorly, it leaks tokens, breaks SEO, or results in confusing redirects and "flicker" between logged-out and logged-in states.
This post covers core concepts, practical patterns for React Router and Next.js, secure storage choices, OAuth/OIDC flows, and common pitfalls—with code and trade-off tables you can drop into your docs.
Table of Contents
Core Concepts (Auth vs. Session vs. Access)
- Authentication: "Who are you?" (login)
- Session / Token: Proof of login (cookie-based session ID or tokens)
- Authorization: "What can you do?" (roles/permissions/scopes)
- State: Where you keep that proof (HTTP-only cookies, in-memory tokens, etc.)
Sessions vs. Tokens
Approach | Pros | Cons |
---|---|---|
Cookie Session (server-managed) | - HTTP-only cookie resists XSS theft - Simple invalidation (delete from server store) - Pairs naturally with SSR/RSC | - CSRF protection required for state-changing requests - Server-side session store (or signed cookie) to manage |
JWT Access Tokens (client-presented) | - Stateless API scaling (no server store) - Works well for mobile/3rd-party clients - Can carry scopes/claims | - Token leakage risk if stored poorly - Revocation is harder (need short TTL + rotation) - Signature/clock skew handling |
Where to Keep the "Proof" (Storage Options)
Storage | Pros | Cons | Use When |
---|---|---|---|
HTTP-only Secure Cookie | - Not readable by JS (mitigates XSS theft) - Sent automatically with fetch (CORS-permitting) | - Needs CSRF defenses for same-site state changes - Cookie domain/path nuances | - Browser apps talking to your own domain |
In-memory (JS variable) | - Not persisted (harder to steal) - Avoids CSRF (not auto-sent) | - Lost on refresh (needs silent refresh) - Vulnerable to XSS exfiltration if present | - SPA with Authorization Code + PKCE; short-lived access + background refresh |
localStorage / sessionStorage | - Persistent across reloads - Simple to implement | - Readable by JS → high XSS risk - Must manually attach on every request | - Generally avoid for long-lived tokens |
React Router: Protecting Routes (SPA)
Minimal "ProtectedRoute" component
// src/auth/ProtectedRoute.jsx
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "./auth-context";
export default function ProtectedRoute({ children }) {
const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading) return <div>Loading...</div>;
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
return children;
}
// src/App.jsx
import { Routes, Route } from "react-router-dom";
import ProtectedRoute from "./auth/ProtectedRoute";
import Dashboard from "./pages/Dashboard";
import Login from "./pages/Login";
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
</Routes>
);
}
Secure fetch helper (cookie sessions)
// src/lib/api.js
export async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (options.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const res = await fetch(`/api${path}`, {
credentials: "include",
headers,
...options,
});
if (res.status === 401) throw new Error("UNAUTHORIZED");
if (res.status === 204) return null;
const ct = res.headers.get("content-type") || "";
return ct.includes("application/json") ? res.json() : res.text();
}
Express (Server) Example: Login & Session Cookie
// server/auth.js (Express example — with cookie parsing & signed cookies)
import express from "express";
import cookieParser from "cookie-parser";
import crypto from "crypto";
const router = express.Router();
// ⚙️ Config
const COOKIE_NAME = "sid";
const COOKIE_SECRET = process.env.COOKIE_SECRET || "dev-only-secret";
const isProd = process.env.NODE_ENV === "production";
// Parse (and verify) cookies on this router
router.use(cookieParser(COOKIE_SECRET));
// Naive in-memory session store (replace with Redis/DB)
const sessions = new Map();
router.post("/login", express.json(), (req, res) => {
const { email, password } = req.body || {};
// TODO: verify email/password against your user store
const userId = "user_123"; // example only
const sessionId = crypto.randomUUID();
sessions.set(sessionId, { userId, createdAt: Date.now() });
res.cookie(COOKIE_NAME, sessionId, {
httpOnly: true,
secure: isProd, // true in production behind HTTPS
sameSite: "lax",
signed: true, // prevents tampering
path: "/",
maxAge: 60 * 60 * 1000, // 1 hour
});
// No JSON body on 204
res.status(204).end();
});
router.post("/logout", (req, res) => {
const sid =
req.signedCookies?.[COOKIE_NAME] ?? req.cookies?.[COOKIE_NAME];
if (sid) sessions.delete(sid);
res.clearCookie(COOKIE_NAME, {
httpOnly: true,
secure: isProd,
sameSite: "lax",
path: "/",
});
res.status(204).end();
});
router.get("/me", (req, res) => {
const sid =
req.signedCookies?.[COOKIE_NAME] ?? req.cookies?.[COOKIE_NAME];
const session = sid && sessions.get(sid);
if (!session) return res.status(401).json({ error: "unauthorized" });
// Example payload — fetch real user data from your DB
res.json({ id: session.userId, email: "user@example.com", role: "admin" });
});
export default router;
/*
In your server entry:
import authRouter from "./server/auth.js";
app.use("/auth", authRouter);
CSRF NOTE:
For cookie-based sessions, protect state-changing routes with SameSite,
CSRF tokens, and/or double-submit; also validate Origin/Referer.
*/
CSRF: For cookie sessions, protect state-changing routes with SameSite, CSRF tokens, and/or double-submit patterns. For APIs used cross-site, also enforce CORS.
Next.js (App Router) Pattern: Middleware + Route Handler
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(req: NextRequest) {
const isAuthed = Boolean(req.cookies.get("sid"));
const url = req.nextUrl;
if (!isAuthed && url.pathname.startsWith("/app")) {
url.pathname = "/login";
return NextResponse.redirect(url);
}
return NextResponse.next();
}
// app/api/login/route.ts
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { email, password } = await req.json();
// TODO: verify
const res = new NextResponse(null, { status: 204 }); // no body on 204
res.cookies.set({
name: "sid",
value: "session-id",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60,
});
return res;
}
With React Server Components, you can read cookies/headers in server components or route handlers and keep secrets off the client.
OAuth 2.1 / OpenID Connect in React (Public Clients)
For SPAs and mobile apps, use Authorization Code with PKCE:
Piece | Purpose | Notes |
---|---|---|
Authorization Code + PKCE | Proves the same client that started auth is the one exchanging the code | Prevents code interception attacks for public clients |
Access Token | Short-lived token to call APIs | Prefer opaque or JWT; keep TTL short |
Refresh Token | Obtain new access tokens silently | Rotate; constrain by audience, device, IP; store securely (cookie or httpOnly if via backend) |
ID Token | Identity claims for the user | Do not use as session on your API |
Skeleton PKCE flow in a React SPA (browser-only)
// auth/pkce.ts
export async function createPkce() {
const codeVerifier = crypto.getRandomValues(new Uint8Array(32))
.reduce((s, b) => s + b.toString(16).padStart(2, "0"), "");
const data = new TextEncoder().encode(codeVerifier);
const digest = await crypto.subtle.digest("SHA-256", data);
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
sessionStorage.setItem("pkce_verifier", codeVerifier);
return codeChallenge;
}
In production, use a vetted SDK from your identity provider rather than hand-rolling the entire flow.
Authorization Models (RBAC vs. ABAC)
Model | Pros | Cons | Examples |
---|---|---|---|
RBAC (Role-Based) | - Simple mental model - Easy UI gating | - Coarse-grained; role explosion | role=admin|editor|viewer |
ABAC (Attribute-Based) | - Fine-grained; policy-driven | - More complex policy engine | department=finance & amount<5000 |
Scopes/Permissions | - API-first; composable | - Can be verbose to manage | orders:read , orders:write |
Common Pitfalls (and Fixes)
Pitfall | Why it happens | Fix |
---|---|---|
Storing tokens in localStorage | Convenient persistence, but readable by JS | Prefer HTTP-only cookies or in-memory + short TTL + silent refresh |
CSRF on cookie sessions | Browser auto-sends cookies cross-site | SameSite=Lax/Strict , CSRF tokens, check Origin/Referer |
Long-lived JWTs without rotation | Compromised token lives "forever" | Short access TTL (5–15 min) + rotating refresh tokens + revoke on logout |
Flicker on protected pages | Client checks run after initial paint | Gate routes in router loaders/middleware or render a skeleton until auth state resolves |
Leaking secrets to the client | Fetching from client with secret API keys | Call secrets from the server (RSC/route handlers); expose only what’s needed |
Putting It Together: Recommended Defaults
- Browser app to your own API: HTTP-only,
Secure
,SameSite=Lax
session cookie, CSRF protection, andfetch(..., { credentials: "include" })
. - Public SPA with external IdP: Authorization Code + PKCE, short-lived access tokens kept in memory, refresh tokens handled by a backend-for-frontend (cookie) or by a vetted SDK with rotation.
- Next.js (App Router): Use middleware to protect routes, route handlers for login/logout, and server components to keep secrets server-side.
Conclusion
React authentication is less about a single library and more about choosing the right primitives for your app: sessions vs. tokens, storage strategy, route protection, and flows like OAuth with PKCE. Favor HTTP-only cookies or in-memory tokens, short-lived credentials, and server-side enforcement (middleware/route handlers) to keep users safe and your UI smooth.
Key Takeaways
- Keep tokens out of JS-readable storage when possible.
- Use short TTL + rotation; revoke on logout and anomalies.
- Gate access before render (middleware/loaders) to avoid flicker.
- Co-locate auth logic with UI, but keep secrets on the server.