Remix + Supabase security — loader / action model

Remix's loader/action model concentrates the entire trust boundary into two function types per route. Every `loader` runs on every navigation that hits that route — one missing auth check means anyone with the URL gets the data. Every `action` handles every write — one missing CSRF or origin check means cross-site forms can mutate your database. Layered on top: Supabase RLS needs to be enabled the same way as any Supabase app, the Remix session cookie needs the right `httpOnly` + `secure` + `sameSite` flags, and the `createCookieSessionStorage` helper ships with defaults that are NOT safe for production. The session secret rotation strategy matters more than in other stacks because Remix sessions are long-lived. Good news: if you adopt a strict pattern (auth check as the first line of every loader; origin check as the first line of every action; Supabase RLS on every table), Remix is as safe as any framework and catches a full class of bugs at compile time via its file-based routing.

What breaks on this stack

Loader returning data without auth

`export async function loader()` runs on every navigation to that route. Forgetting to check the session means anyone with the URL gets the data — including search engines crawling the page. Make every non-public loader start with a session check that redirects unauthenticated users.

Action without origin or CSRF check

Remix actions accept cross-origin POST requests by default. A malicious site can submit a form targeting your action endpoint. Add `if (request.headers.get('origin') !== request.url)` or a CSRF token check at the top of every state-changing action.

Session cookie too loose

createCookieSessionStorage defaults often miss httpOnly, secure, or sameSite. Explicitly set all three in production, plus a strong `secrets` array with rotation capability.

Read the guide →

Supabase RLS off on some tables

Same pattern as any Supabase app — check /guides/supabase-rls-misconfiguration for the full fix.

Read the guide →

Server-side Supabase client using wrong key

In loaders you sometimes need the service-role key to bypass RLS for admin operations. Keep that client entirely server-side and use the anon key for any user-facing query.

Pre-ship checklist

  • Every loader has an auth check as the first statement (or is explicitly marked public)
  • Every action verifies origin or CSRF token before mutating
  • Session cookie: httpOnly + secure + sameSite=lax (or strict for high-value actions)
  • Session secrets array has at least two keys for rotation
  • Supabase RLS enabled on every table with user-scoped policies
  • Supabase service-role client never used inside a loader reachable from the browser
  • Rate limiting added to action endpoints
  • Content-Security-Policy set via a root-level meta or header
  • Error responses don't leak stack traces or internal paths

Starter config

// app/entry.server.tsx — CSP + security headers
import { PassThrough } from "node:stream";
// ... Remix's default streamed render boilerplate ...
// Add security headers on the response:
responseHeaders.set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");
responseHeaders.set("X-Content-Type-Options", "nosniff");
responseHeaders.set("X-Frame-Options", "DENY");
responseHeaders.set("Referrer-Policy", "strict-origin-when-cross-origin");

// app/lib/auth.server.ts — session helper
import { createCookieSessionStorage } from "@remix-run/node";
export const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: "__session",
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    secrets: (process.env.SESSION_SECRETS ?? "").split(","),
    path: "/",
    maxAge: 60 * 60 * 24 * 7, // 7 days
  },
});

export async function requireUser(request: Request) {
  const session = await sessionStorage.getSession(request.headers.get("Cookie"));
  const userId = session.get("userId");
  if (!userId) throw redirect("/login");
  return userId;
}

// app/routes/orders.$id.tsx — loader + action pattern
export async function loader({ request, params }: LoaderFunctionArgs) {
  const userId = await requireUser(request);
  const { data } = await supabase.from("orders").select().eq("id", params.id).eq("user_id", userId).single();
  return json(data);
}
export async function action({ request, params }: ActionFunctionArgs) {
  const userId = await requireUser(request);
  if (new URL(request.url).origin !== request.headers.get("origin")) {
    throw new Response("bad origin", { status: 400 });
  }
  // ... mutation with user_id enforced ...
}