Skip to main content
Technology & EngineeringRemix297 lines

Authentication

Authentication patterns in Remix using sessions, cookies, JWT, OAuth flows, and route protection

Quick Summary17 lines
You are an expert in implementing authentication in Remix applications, including cookie-based sessions, OAuth flows, protected routes, and role-based access control.

## Key Points

- Always set `httpOnly`, `secure` (in production), and `sameSite` on session cookies.
- Store the minimal data in the session (user ID only) and fetch the full user in each loader.
- Use `session.flash()` for one-time messages (success toasts, logout confirmations).
- Hash passwords with bcrypt or argon2 — never store plaintext passwords.
- Validate `redirectTo` parameters to prevent open redirect attacks (ensure it starts with `/`).
- Set `SESSION_SECRET` via environment variables, never hard-code secrets.
- Forgetting to `commitSession` after modifying the session — changes are lost unless the `Set-Cookie` header is sent.
- Not destroying the session on logout — setting `userId` to `null` still leaves a valid session cookie.
- Using `redirect()` inside a try/catch in an action — the redirect throws a Response and gets caught.
- Checking auth only in the component instead of the loader — the data will still be sent to the client before the component runs.
- Not validating the OAuth `state` parameter — this opens the app to CSRF attacks during the OAuth flow.
skilldb get remix-skills/AuthenticationFull skill: 297 lines
Paste into your CLAUDE.md or agent config

Authentication — Remix

You are an expert in implementing authentication in Remix applications, including cookie-based sessions, OAuth flows, protected routes, and role-based access control.

Overview

Remix handles authentication server-side using its built-in session and cookie APIs. Since loaders and actions run on the server, auth checks happen before any HTML is sent to the client. Remix provides createCookieSessionStorage, createSessionStorage, and related utilities for managing sessions without external dependencies. For OAuth and social logins, you integrate provider libraries within loaders and actions.

Core Concepts

Cookie Session Storage

// app/services/session.server.ts
import { createCookieSessionStorage, redirect } from "@remix-run/node";

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

export async function getSession(request: Request) {
  return sessionStorage.getSession(request.headers.get("Cookie"));
}

export async function createUserSession(userId: string, redirectTo: string) {
  const session = await sessionStorage.getSession();
  session.set("userId", userId);
  return redirect(redirectTo, {
    headers: {
      "Set-Cookie": await sessionStorage.commitSession(session),
    },
  });
}

export async function destroyUserSession(request: Request) {
  const session = await getSession(request);
  return redirect("/login", {
    headers: {
      "Set-Cookie": await sessionStorage.destroySession(session),
    },
  });
}

Requiring Authentication

// app/services/auth.server.ts
import { getSession } from "./session.server";
import { redirect } from "@remix-run/node";

export async function requireUserId(request: Request): Promise<string> {
  const session = await getSession(request);
  const userId = session.get("userId");
  if (!userId) {
    const url = new URL(request.url);
    throw redirect(`/login?redirectTo=${encodeURIComponent(url.pathname)}`);
  }
  return userId;
}

export async function requireUser(request: Request) {
  const userId = await requireUserId(request);
  const user = await getUserById(userId);
  if (!user) {
    throw await destroyUserSession(request);
  }
  return user;
}

Implementation Patterns

Login Form

// app/routes/login.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData, useSearchParams } from "@remix-run/react";
import { createUserSession, getSession } from "~/services/session.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const session = await getSession(request);
  if (session.get("userId")) {
    return redirect("/dashboard");
  }
  return json({});
}

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
  const redirectTo = (formData.get("redirectTo") as string) || "/dashboard";

  const user = await verifyLogin(email, password);
  if (!user) {
    return json({ error: "Invalid email or password" }, { status: 401 });
  }

  return createUserSession(user.id, redirectTo);
}

export default function Login() {
  const actionData = useActionData<typeof action>();
  const [searchParams] = useSearchParams();

  return (
    <Form method="post">
      <input
        type="hidden"
        name="redirectTo"
        value={searchParams.get("redirectTo") ?? ""}
      />
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" required />
      </div>
      {actionData?.error && <p className="error">{actionData.error}</p>}
      <button type="submit">Log In</button>
    </Form>
  );
}

Protected Route Loader

// app/routes/dashboard.tsx
import { requireUser } from "~/services/auth.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireUser(request);
  return json({ user });
}

export default function Dashboard() {
  const { user } = useLoaderData<typeof loader>();
  return <h1>Welcome, {user.name}</h1>;
}

Role-Based Access Control

export async function requireRole(request: Request, role: string) {
  const user = await requireUser(request);
  if (user.role !== role) {
    throw new Response("Forbidden", { status: 403 });
  }
  return user;
}

// In an admin route loader:
export async function loader({ request }: LoaderFunctionArgs) {
  const admin = await requireRole(request, "admin");
  const stats = await getAdminStats();
  return json({ admin, stats });
}

OAuth with a Provider (Example: GitHub)

// app/routes/auth.github.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const clientId = process.env.GITHUB_CLIENT_ID!;
  const redirectUri = `${new URL(request.url).origin}/auth/github/callback`;
  const state = crypto.randomUUID();

  const session = await getSession(request);
  session.set("oauth_state", state);

  const url = new URL("https://github.com/login/oauth/authorize");
  url.searchParams.set("client_id", clientId);
  url.searchParams.set("redirect_uri", redirectUri);
  url.searchParams.set("state", state);
  url.searchParams.set("scope", "read:user user:email");

  return redirect(url.toString(), {
    headers: { "Set-Cookie": await commitSession(session) },
  });
}
// app/routes/auth.github.callback.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code")!;
  const state = url.searchParams.get("state")!;

  const session = await getSession(request);
  if (state !== session.get("oauth_state")) {
    throw new Response("Invalid state", { status: 400 });
  }

  const tokenResponse = await fetch(
    "https://github.com/login/oauth/access_token",
    {
      method: "POST",
      headers: { Accept: "application/json", "Content-Type": "application/json" },
      body: JSON.stringify({
        client_id: process.env.GITHUB_CLIENT_ID,
        client_secret: process.env.GITHUB_CLIENT_SECRET,
        code,
      }),
    }
  );
  const { access_token } = await tokenResponse.json();

  const userResponse = await fetch("https://api.github.com/user", {
    headers: { Authorization: `Bearer ${access_token}` },
  });
  const githubUser = await userResponse.json();

  const user = await findOrCreateUser(githubUser);
  return createUserSession(user.id, "/dashboard");
}

Flash Messages

export async function logout(request: Request) {
  const session = await getSession(request);
  session.flash("toast", "You have been logged out");
  return redirect("/login", {
    headers: { "Set-Cookie": await commitSession(session) },
  });
}

// In the login loader, read the flash:
export async function loader({ request }: LoaderFunctionArgs) {
  const session = await getSession(request);
  const toast = session.get("toast") || null;
  return json({ toast }, {
    headers: { "Set-Cookie": await commitSession(session) },
  });
}

Core Philosophy

Authentication in Remix happens entirely on the server, before any HTML reaches the client. Loaders and actions are the gatekeepers: a protected route's loader checks the session, and if the user is not authenticated, it redirects to the login page before rendering anything. This server-first model ensures that unauthenticated users never receive protected content, not even in the initial HTML payload.

The session system is deliberately low-level. Remix provides createCookieSessionStorage and related primitives rather than a pre-built auth framework. This is an intentional design choice that gives you full control over session structure, storage backends, and cookie configuration. The trade-off is more boilerplate, but the benefit is transparency: you can see exactly how sessions are created, validated, and destroyed without digging into a library's internals.

Authentication checks in Remix are composable utility functions, not middleware magic. Functions like requireUserId and requireRole are called explicitly in each loader, making the security boundary visible in every route file. This explicitness is a feature: there is no hidden middleware that might be misconfigured, and code review can immediately verify that every protected route calls the appropriate auth check.

Anti-Patterns

  • Checking authentication only in the React component instead of the loader. The component runs after the loader's data is already sent to the client. If you check auth in the component and redirect there, the protected data has already been transmitted in the HTML payload.

  • Storing full user objects in the session cookie. Session cookies have size limits (typically 4 KB). Store only the user ID in the session and fetch the full user record in each loader. This also ensures you always work with fresh user data.

  • Forgetting to call commitSession after modifying session data. Session changes are only persisted when the Set-Cookie header is sent in the response. Modifying the session without committing it silently loses the changes.

  • Accepting unvalidated redirectTo query parameters. Open redirect attacks occur when the redirect target is not validated. Always ensure the redirectTo value starts with / and does not contain // or an external URL scheme.

  • Using redirect() inside a try/catch in actions or loaders. Since redirect() throws a Response, catching it in a try block prevents the redirect from working. Let Response throws propagate, or explicitly check for and re-throw Response instances in your catch block.

Best Practices

  • Always set httpOnly, secure (in production), and sameSite on session cookies.
  • Store the minimal data in the session (user ID only) and fetch the full user in each loader.
  • Use session.flash() for one-time messages (success toasts, logout confirmations).
  • Hash passwords with bcrypt or argon2 — never store plaintext passwords.
  • Validate redirectTo parameters to prevent open redirect attacks (ensure it starts with /).
  • Set SESSION_SECRET via environment variables, never hard-code secrets.

Common Pitfalls

  • Forgetting to commitSession after modifying the session — changes are lost unless the Set-Cookie header is sent.
  • Not destroying the session on logout — setting userId to null still leaves a valid session cookie.
  • Using redirect() inside a try/catch in an action — the redirect throws a Response and gets caught.
  • Checking auth only in the component instead of the loader — the data will still be sent to the client before the component runs.
  • Not validating the OAuth state parameter — this opens the app to CSRF attacks during the OAuth flow.

Install this skill directly: skilldb add remix-skills

Get CLI access →