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)
    Sessions vs. Tokens
    Where to Keep the "Proof" (Storage Options)
    React Router: Protecting Routes (SPA)
    Minimal "ProtectedRoute" component
    Secure fetch helper (cookie sessions)
    Express (Server) Example: Login & Session Cookie
    Next.js (App Router) Pattern: Middleware + Route Handler
    OAuth 2.1 / OpenID Connect in React (Public Clients)
    Skeleton PKCE flow in a React SPA (browser-only)
    Authorization Models (RBAC vs. ABAC)
    Common Pitfalls (and Fixes)
    Putting It Together: Recommended Defaults
    Conclusion
    Key Takeaways

    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
    ^

    ApproachProsCons
    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)
    ^

    StorageProsConsUse 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, and fetch(..., { 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.