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 ...
}