Application & framework security

Remix security checklist

React Router 7 superseded Remix. Stay on Remix v2 with @arcjet/remix, or migrate and use @arcjet/react-router. Keep side effects out of module scope, sign cookies, mutate only in actions, set headers in entry.server, validate form data, and do not write uploads to app-server disk.

7 min read
In short: React Router 7 superseded Remix. Stay on Remix v2 with @arcjet/remix, or migrate and use @arcjet/react-router. Keep side effects out of module scope, sign cookies, mutate only in actions, set headers in entry.server, validate form data, and do not write uploads to app-server disk.

Remix v2 or React Router 7?

React Router 7 superseded Remix. Start new apps on React Router 7. Existing Remix v2 apps can stay on Remix until you migrate; the security checklist is the same, but the package names differ.

AppFramework packagesArcjet SDK
Remix v2

@remix-run/node, @remix-run/react

@arcjet/remix
React Router 7

react-router, @react-router/node

@arcjet/react-router

Loaders and actions still run on the server. Cookies, CSRF, and header rules still apply. The rest of this article uses Remix v2 names (LoaderFunctionArgs, @remix-run/node). On React Router 7, swap the types for Route.LoaderArgs and import Arcjet from @arcjet/react-router.

Use this checklist. Each item is a control you can verify.

  • Patch and lock dependencies.
  • Keep side effects out of module scope.
  • Keep secrets in .server.ts modules and out of loader returns.
  • Sign cookies and authorize in every loader and action.
  • Mutate only in actions, never in loaders.
  • Set security headers in entry.server (and handleDataRequest).
  • Validate form data, query params, and cookies with a schema.
  • Do not write user uploads to your app server disk.

The web app security checklist covers abuse that appears after the app is live. The Next.js security checklist is the parallel guide if you run both frameworks.

How do you keep Remix dependencies safe?

Commit the lockfile. Review updates weekly. Dependabot or Renovate plus npm audit in CI is the minimum.

JavaScript packages change often. Skipping several major versions makes a later CVE an emergency. Treat install scripts, new network access, and ownership changes as review events. Prefer a short function you maintain over a trivial dependency.

Why must Remix avoid module-scope side effects?

The Remix compiler strips server-only exports from the browser bundle. That only works if sensitive work stays inside loader and action. Code that runs when the module is imported can execute too early or leak into the client graph.

import { auth } from "../auth.server";
import type { LoaderFunctionArgs } from "@remix-run/node";
// Dangerous: runs at import time.
const authStatus = auth.verifySession();
export async function loader({ request }: LoaderFunctionArgs) {
return Response.json({ status: authStatus });
}

Move the call into the loader:

import { auth } from "../auth.server";
import type { LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
const authStatus = await auth.verifySession(request);
return Response.json({ status: authStatus });
}

Anything you return from a loader is visible to the client, even if a component never renders it. Treat loader JSON as a public API.

How should Remix environment variables be handled?

Put secret access in *.server.ts files. Those modules never ship to the browser. Read process.env inside a loader or action. Do not put a secret on window.ENV.

window.ENV is a convenience for public values such as a marketing site URL. If a value can mint a session or open a database, it does not belong there.

Prefer a secrets manager in production. Child processes inherit environment variables, and the values often appear in crash dumps.

How do you authenticate and authorize Remix routes?

Create cookies with createCookie or a session storage helper. Store cookie definitions in a *.server.ts file. Remix signs cookies when you provide secrets. Keep those secrets unguessable and rotate by putting the new secret first in the array so old cookies still decode.

Session storage options include cookie sessions, file sessions, Workers KV, and DynamoDB. Read and write the session in loaders and actions. Do not trust a client-supplied user ID.

Authorization is a second check. A valid session is not permission to edit another team's project. Look up the object with the caller's tenant or owner constraint before you return or mutate it.

You can use Auth.js, Clerk, Better Auth, or a session you control. The important part is that every loader and action that touches private data performs the check.

How do you prevent CSRF in Remix?

Browsers that honor SameSite=Lax (Remix's cookie default) block most cross-site cookie sends on POST. That is not enough if you accept mutations from GET or from a loader.

Never log out, change email, or update a password in a loader. Put every mutation in an action. A loader that mutates is a CSRF bug.

Add an anti-CSRF token for sensitive actions if you need defense beyond SameSite. remix-utils includes CSRF helpers, safe redirects, and CORS utilities. Validate the redirect target against an allowlist so an open redirect cannot bounce users to a phishing page.

Where should Remix set security headers?

Route headers exports do not merge the way most people expect. A child route replaces parent headers unless you merge them yourself. A child Cache-Control can silently weaken a parent policy.

Set security headers in entry.server so they apply to document requests. For data requests, set them in handleDataRequest.

function applySecurityHeaders(headers: Headers) {
headers.set("X-Content-Type-Options", "nosniff");
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
headers.set("X-Frame-Options", "SAMEORIGIN");
headers.set(
"Content-Security-Policy",
"default-src 'self'; frame-ancestors 'self'",
);
headers.set(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains",
);
headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
}
export function handleDataRequest(response: Response) {
applySecurityHeaders(response.headers);
return response;
}

If Remix sits behind Express, Helmet can set the same headers at the HTTP layer. Still set them in entry.server if some requests never hit that Express middleware.

How do you validate Remix form data?

React escapes string children. That does not protect href, style, dangerouslySetInnerHTML, Markdown rendering, or SQL.

Validate request.formData(), query strings, and session fields with Zod or Valibot in the action or loader. Return field errors with 400 or 401. Do not trust the HTML required attribute.

import type { ActionFunctionArgs } from "@remix-run/node";
import { z } from "zod";
const loginSchema = z.object({
email: z.string().email().max(254),
password: z.string().min(8).max(128),
});
export async function action({ request }: ActionFunctionArgs) {
const parsed = loginSchema.safeParse(
Object.fromEntries(await request.formData()),
);
if (!parsed.success) {
return Response.json(
{ errors: parsed.error.flatten().fieldErrors },
{ status: 400 },
);
}
// authenticate parsed.data
}

Use the same schema on the client for faster feedback if you want, but keep the server parse as the source of truth.

How should Remix handle file uploads?

Do not persist user files on the application server disk. Remix upload helpers can filter by size and type, but content-type is not proof of content. A renamed executable will pass a naive MIME check.

Send uploads to object storage (S3 or equivalent) with a short-lived, scoped credential. Scan the object after upload if other users can download the file. Serve downloads through a handler that authorizes the object, not through a public bucket listing.

How do you screen Remix requests at runtime?

Framework hygiene does not stop credential stuffing or a scraped loader. Screen the request in the loader or action before you hit the database.

Remix v2 with @arcjet/remix v1:

import arcjet, { detectBot, shield, slidingWindow } from "@arcjet/remix";
import type { ActionFunctionArgs } from "@remix-run/node";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
shield({ mode: "LIVE" }),
detectBot({ mode: "LIVE", allow: [] }),
slidingWindow({ mode: "LIVE", interval: 60, max: 20 }),
],
});
export async function action(args: ActionFunctionArgs) {
const decision = await aj.protect(args);
if (decision.isDenied()) {
if (decision.reason.isRateLimit()) {
throw new Response("Too many requests", { status: 429 });
}
throw new Response("Forbidden", { status: 403 });
}
// validate, authenticate, then mutate
}

React Router 7 uses the same rules. Import from @arcjet/react-router and pass Route.ActionArgs (or Route.LoaderArgs) to protect().

Allow CATEGORY:SEARCH_ENGINE on public document loaders if you want crawlers. Keep allow: [] on login and password-reset actions.

What should you do after this checklist?

Confirm each item against one sensitive route: login, password reset, and a mutation that changes account email. Then apply the web app security checklist for abuse that only shows up under real traffic.

Frequently asked questions

Is Remix still the right framework to start with?

New apps should start on React Router 7, which superseded Remix. Existing Remix v2 apps can stay on Remix until you migrate. The checklist is the same; package names differ (@arcjet/remix vs @arcjet/react-router).

Why can't I put auth checks at module scope in Remix?

Import-time code can run too early or leak into the client bundle. Put session and database work inside loader and action. Anything a loader returns is visible to the client.

Can I log out from a Remix loader?

No. Mutations in a loader are a CSRF risk. Put logout, email change, and password update in an action.

Where do Remix security headers belong?

Set them in entry.server and handleDataRequest. Route headers exports replace parents instead of merging, so a child Cache-Control can weaken a parent policy.

Application security in your code

Protect your application with Arcjet

Get rate limits, bot detection, and attack blocking in your request handlers.