Remix
Updated
12 min read

Remix Security Checklist

A security checklist for Remix applications: dependencies & updates, module constraints, environment variables, authentication and authorization, cross-site request forgery, security headers, validation, and file uploads.

Remix Security Checklist

Remix has been growing in popularity as a more lightweight framework that closely follows web standards. As an alternative to Next.js, it has tried to take a more minimalist path. Perhaps that’s why OpenAI recently migrated the ChatGPT UI from Next.js to Remix!

Like Next.js, building frontend UI with React includes basic security out of the box - vulnerabilities like cross site scripting are much less likely due to the design of the framework. However, you still need to think about security.

Good security is built in layers, creating walls behind ones that may be breached. Although total security is impossible, there are several additional measures you can take to mitigate the risk of attack. Defense-in-depth ensures that if one mechanism fails, others still protect the application.

We recently released the Arcjet security as code SDK for Remix to bring bot detection, PII redaction, signup form spam protection and rate limiting to Remix. This article will cover some of the important areas from our research about how to improve your Remix app security.

1. Dependencies & Updates

With the frequency and severity of supply chain attacks on the rise, one of the easiest ways to keep your website and user base safe is to stay current on the latest patches and updates. Though this vulnerability class has been gaining more attention and registries are making changes to minimize the threat level, third-party integrations will always pose a risk.

At Arcjet we review non-critical dependency updates every week using Dependabot with Socket pull request analysis to help us keep an eye on what’s included in those updates. Alternatively, npm audit can be used to assist you in keeping up-to-date with the latest releases that have addressed publicly known vulnerabilities.

Attacks such as dependency confusion and package hijacking have been responsible for major disruptions. Socket helps us ensure we mitigate those risks by highlighting unusual updates. To minimize your attack surface, consider implementing the functionality provided by trivial packages yourself.

The JavaScript ecosystem has a relatively high churn rate of updates which can be challenging to keep up to date with, particularly when there are breaking changes. This is a pain, but it’s more painful to be forced through several major version changes if you don’t keep up and then there’s a critical vulnerability that is only addressed in the latest release!

2. Module constraints

Server-only code will be automatically removed from what gets sent to the browser by the Remix compiler. However, to ensure this works properly, you must avoid module side effects.

Certain operations, such as logging or API calls, that occur immediately when a module is imported can expose sensitive information or cause errors:

import { auth } from "../auth.server";
import { useLoaderData } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node";

// DANGEROUS: Immediately tries to verify auth on module import.
const authStatus = auth.verifySession();
// DANGEROUS: Potential side effect, may expose auth logic.
console.log("Auth Status:", authStatus);

export async function loader({ request }: LoaderFunctionArgs) {
  return Response.json({
    users: await auth.getAuthorizedUsers(),
    status: authStatus // Using the problematic module-level variable.
  });
}

export default function Users() {
  const data = useLoaderData<typeof loader>();
  return <div>{/* render users */}</div>;
}

Instead, the code responsible for the side effect should be wrapped in the loader function:

import { auth } from "../auth.server";
import { useLoaderData } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node";

export async function loader({ request }: LoaderFunctionArgs) {
  // Side effect properly contained within the loader.
  const authStatus = await auth.verifySession();
  console.log("Auth Status:", authStatus); // Safe implementation.
  
  return Response.json({
    users: await auth.getAuthorizedUsers(),
    status: authStatus
  });
}

export default function Users() {
  const data = useLoaderData<typeof loader>();
  return <div>{/* render users */}</div>;
}

Placing sensitive operations (e.g., database queries, verifying sessions) directly in an imported module can unintentionally leak this logic to the client bundle, or execute it too early.

Always wrap such operations in a loader or action so that Remix’s server-only compilation can properly exclude them from client code. This approach also helps ensure that any credentials or personally identifiable information (PII) are only accessed by server functions, further reducing the likelihood of a security breach.

3. Environment variables

All your environment variables, such as API keys or database URLs, should be kept server-side to avoid accidental exposure. Any file with the .server.ts suffix will only ever be executed on the server.

You can access server-side environment variables inside your loader because loaders only ever run on the server. However, any value that is returned by the loader will be available on the client. Use sensitive environment variables inside the loader, but do not return them.

Anything placed into window.ENV will be exposed to the browser. While it’s convenient to pass environment variables to the client via a context like window.ENV, you must ensure only non-sensitive values are exposed - like a public-facing API base URL.

The ideal situation is to avoid using environment variables for any secrets - this is a common anti-pattern that should be avoided.

4. Authentication & Authorization

Securing routes in Remix can be accomplished with its native utilities. Session cookies can be created either with the createCookie utility or by using a session storage object which will be checked in a loader or action when reading or writing data.

createCookie

This utility creates a logical container to manage a browser cookie issued by the server. Any attributes can be set by adding to the options object or during serialize() when the Set-Cookie response header is generated.

Since cookies can be easily tampered with, Remix will automatically sign a cookie to verify its contents and ensure its integrity. The secrets that will be used for signing are sourced from the secrets property which stores a string value array. 

If multiple secrets are provided, the one at index position 0 will be used to sign all outgoing cookies. However, any cookies that were signed with older secrets will still successfully decode. This is useful when you want to rotate secrets. It is critical that any secrets used are complex enough to be unguessable, as cookies could be forged if a malicious attacker is aware of their correct value.

It is recommended that all created cookies are stored in a *.server.ts file and then imported into your route modules. Files with the .server.ts suffix are never sent to the client.

Session Storage

There are a variety of session storage strategies available in Remix, as well as the ability to create a custom one:

Define your session storage object in app/session.ts to act as a centralized location for routes to access session data.

Using Cookies

Once a cookie is generated the .parse() method can be used to extract and return its value. Then conditionals can be defined in your loader and action functions:

// app/routes/_index.tsx
// Remember the user's preference for banner visibility using a cookie.

import { json, redirect } from "@remix-run/node";
import { useLoaderData, Form } from "@remix-run/react";
import { userPrefs } from "~/cookies.server";

// Return showBanner value from userPrefs cookie.
export async function loader({ request }) {
  const cookieHeader = request.headers.get("Cookie");
  const cookie = (await userPrefs.parse(cookieHeader)) || {};
  return json({ showBanner: cookie.showBanner });
}

// Update showBanner value to false if bannerVisibility is set to hidden.
export async function action({ request }) {
  const cookieHeader = request.headers.get("Cookie");
  const cookie = (await userPrefs.parse(cookieHeader)) || {};
  const bodyParams = await request.formData();

  if (bodyParams.get("bannerVisibility") === "hidden") {
    cookie.showBanner = false;
  }

  // Serialize updated cookie and set in redirect response.
  return redirect("/", {
    headers: {
      "Set-Cookie": await userPrefs.serialize(cookie),
    },
  });
}

export default function Home() {
  const { showBanner } = useLoaderData();

  // Form to hide the banner.
  return (
    <div>
      {showBanner && (
        <div>
          <Form method="post">
            <input type="hidden" name="bannerVisibility" value="hidden" />
            <button type="submit">Hide</button>
          </Form>
        </div>
      )}
      <h1>Welcome!</h1>
    </div>
  );
}

Be aware that any data returned from a loader will be exposed to the client, even if it is not rendered in a component. So treat these with the same care as you would give a public API endpoint.

External Solutions

Alternatively, third-party authentication libraries such as Clerk and Better Auth provide an easy way to integrate robust identity management.

5. Cross-Site Request Forgery

Cross-Site Request Forgery (CSRF) attacks trick victims into submitting requests using their authenticated session. Any functionality that can be executed by authenticated users can be exploited. This includes functionality such as updating the account password, changing the email address associated with the account, deleting the account, etc.

The majority of browsers have built-in protection against CSRF attacks as they support the SameSite cookie attribute which restricts the inclusion of cookies in requests initiated by another website. Remix cookies are set to use SameSite=Lax by default.

WARNING: It is important that logout functions or any mutations are performed in an action and not a loader or you will put users at risk of a CSRF attack. View the official documentation for more information.

remix-utils

To add an extra layer of protection against CSRF attacks, you can use the remix-utils library. This library also offers other security features to compensate for the lack of native security in Remix, including safe redirects and CORS implementation.

6. Security Headers

Each route in Remix can set its own HTTP headers with the HeadersFunction:

import type { HeadersFunction } from "@remix-run/node";

export const headers: HeadersFunction = () => ({
  "header-name-a": "value",
  "header-name-b": "value"
});

However, the headers returned depend on the nesting level of the route:

  • When a route defines headers, only those headers are used by default.
  • Parent headers are only included if they are explicitly merged. If a child and parent share the same header, the value of the child's header overwrites the parent.
  • If a child's loader function throws an error and that error is handled by a parent route, then the parent's headers are used.
  • When a route doesn't define headers, Remix will traverse up the route hierarchy one parent at a time until it finds headers.

This is important to be aware of as you may cache content for longer than intended with a more aggressive child route Cache-Control header. You can avoid any overwriting by only defining headers in your childless routes. 

As such, security headers in Remix should be set using the  entry.server.ts file so they can be applied to every request. For data requests, you must use the handleDataRequest function:

export function handleDataRequest(
  response: Response,
  {
    request,
    params,
    context,
  }: LoaderFunctionArgs | ActionFunctionArgs
) {
  response.headers.set("X-Custom-Header", "value");
  return response;
}

With this knowledge, there are several security headers and attributes that you should use to protect your application.

// app/entry.server.tsx

import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { renderToString } from "react-dom/server";

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext,
  loadContext: AppLoadContext
) {
  const markup = renderToString(
    <RemixServer context={remixContext} url={request.url} />
  );

  // Set security headers.
  // Interpret response as HTML.
  responseHeaders.set("Content-Type", "text/html");
  // Prevent clickjacking attacks.
  responseHeaders.set("X-Frame-Options", "SAMEORIGIN");
  // Enforces Content-Type.
  responseHeaders.set("X-Content-Type-Options", "nosniff");
  // Only include path for same-origin requests.
  responseHeaders.set("Referrer-Policy", "strict-origin-when-cross-origin");
  // Only allow same-origin resources.
  responseHeaders.set(
    "Content-Security-Policy",
    "default-src 'self'; script-src 'self'; style-src 'self';"
  );
  // Only use HTTPS.
  responseHeaders.set(
    "Strict-Transport-Security",
    "max-age=31536000; includeSubDomains"
  );
  // User device protection.
  responseHeaders.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
  // Block cross-origin window access.
  responseHeaders.set("Cross-Origin-Opener-Policy", "same-origin");
  // Block cross-origin resource embedding.
  responseHeaders.set("Cross-Origin-Resource-Policy", "same-origin");
  // Enable origin-keyed agent clustering.
  responseHeaders.set("Origin-Agent-Cluster", "?1");
  // Prevent browsers from DNS prefetching.
  responseHeaders.set("X-DNS-Prefetch-Control", "off");
  // Block Adobe from loading domain data.
  responseHeaders.set("X-Permitted-Cross-Domain-Policies", "none");

  return new Response("<!DOCTYPE html>" + markup, {
    headers: responseHeaders,
    status: responseStatusCode,
  });
}

In addition to setting them manually, you can also use Helmet.js as Remix can be integrated with Express:

import express from "express";
import helmet from "helmet";
import { createRequestHandler } from "@remix-run/express";

const app = express();

// Use Helmet to set security headers.
app.use(helmet());

// Serve static files from the public directory.
app.use(express.static("public"));

// Handle all requests with Remix.
app.all(
  "*",
  createRequestHandler({
    getLoadContext() {
      // Whatever you return here will be passed as `context` to your loaders.
    },
  })
);

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`Server is listening on port ${port}`);
});

7. Validation

React, and by extension Remix, automatically escapes strings used in dynamic content by HTML encoding characters used in injection attacks. This provides a base level of protection unless the input is used within an anchor tag's href attribute, style attributes, and dangerouslySetInnerHTML.

Additional bypasses have also been found when parsing JSON-objects and converting Markdown to HTML.

At Arcjet, we recommend using Zod as it is designed to work seamlessly with TypeScript, allowing you to declare your schema once and use it for both static checking and runtime validation:

// app/schemas/auth.ts

import { z } from 'zod';
import isAlphanumeric from 'validator/lib/isAlphanumeric';

export const loginSchema = z.object({
  username: z.string()
    .min(3, "Username must be at least 3 characters long.")
    .max(20, "Username cannot exceed 20 characters.")
    .refine((val) => isAlphanumeric(val, "en-US"), // Sets language locale.
      "Username can only contain letters and numbers."),

  email: z.string()
    .min(1, "Email is required.")
    .email("Please enter a valid email address."),

  password: z.string()
    .min(8, "Password must be at least 8 characters long.")
    .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, 
      "Password must contain at least one uppercase letter, one lowercase letter, and one number."),

  confirmPassword: z.string()
    .min(8, "Please confirm your password."),
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: "Passwords do not match.",
    path: ["confirmPassword"], // Associates error with field.
  }
);

export type LoginInput = z.infer<typeof loginSchema>;

This schema can then be imported and used in an action:

// app/routes/login.tsx

// ...imports

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const data = Object.fromEntries(formData);
  
  const result = loginSchema.safeParse(data);
  if (!result.success) {
    return Response.json(
      { errors: result.error.flatten().fieldErrors },
      { status: 400 }
    );
  }

  const { email, password } = result.data;
  const user = await login(email, password);
  if (!user) {
    return Response.json(
      { errors: { form: "Invalid credentials." } },
      { status: 401 }
    );
  }

  // Valid credentials create a session and redirects user to dashboard.
  return createUserSession(user.id, "/dashboard");
}

export default function Login() {
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const { errors, validate } = useFormValidation(loginSchema);
  // Route action being called due to non GET form submission.
  const isSubmitting = navigation.state === "submitting";
  // ...

In the form, any fields that fail validation will display an error that is sent from the server:

// app/routes/login.tsx

// Within <Form method="post">.
<div>
  <input name="email" type="email" required />
  {actionData?.errors?.email && (
    <div>{actionData.errors.email[0]}</div>
  )}
</div>

The same Zod schema can also be used for client-side validation using a hook to provide immediate client-side validation before the form submission reaches the server:

// app/hooks/useFormValidation.ts

import { useState } from "react";
import type { z } from "zod";

export function useFormValidation<T extends z.ZodType>(schema: T) {
  const [errors, setErrors] = useState<z.inferFlattenedErrors<T>['fieldErrors']>({});

  const validate = (formData: FormData) => {
    const data = Object.fromEntries(formData);
    const result = schema.safeParse(data);
    
    if (!result.success) {
      setErrors(result.error.flatten().fieldErrors);
      return false;
    }
    
    setErrors({});
    return true;
  };

  return { errors, validate };
}
// app/routes/login.tsx

// Added to the Login() function.
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
  const form = event.currentTarget;
  const formData = new FormData(form);
    
  if (!validate(formData)) {
    event.preventDefault();
  }
};
// app/routes/login.tsx

<Form method="post" onSubmit={handleSubmit}>
  <div>
    <input name="email" type="email" required />
    // Client-side OR server-side errors.
    {(errors.email || actionData?.errors?.email) && (
      <div>
        {errors.email?.[0] || actionData?.errors?.email?.[0]}
      </div>
    )}
  </div>

Validation can also be performed on GET query parameters and cookies:

import type { LoaderFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { getSession } from "~/session.server";

// Example query: ?page=2&sort=desc
const querySchema = z.object({
  // .coerce converts page parameter string of 2 to number.
  // page must be > 1.
  page: z.coerce.number().min(1).default(1),
  // sort must be either "asc" or "desc", defaults to "asc" if missing.
  sort: z.enum(["asc", "desc"]).default("asc"),
});

// userId must be non-empty string.
const sessionSchema = z.object({ userId: z.string().min(1) });

export async function loader({ request }: LoaderFunctionArgs) {
  try {
    const url = new URL(request.url);
    const queryParams = Object.fromEntries(url.searchParams);
    const validatedQuery = querySchema.parse(queryParams);

    const session = await getSession(request.headers.get("Cookie"));
    const sessionData = { userId: session.get("userId") };
    const validatedSession = sessionSchema.parse(sessionData);

    return Response.json({ query: validatedQuery });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return Response.json(
        { error: error.flatten().fieldErrors },
        { status: 400 }
      );
    }
    throw error;
  }
}

8. File Uploads

If your application allows users to upload files, it is essential to implement security measures to mitigate the risk of attackers uploading and executing malicious code in the context of your application.

In Remix, you can use the unstable_createFileUploadHandler and unstable_createMemoryUploadHandler utilities to filter uploads based on file characteristics. However, configuring restrictions to meet your needs and be secure can be overtly complex.

Instead of dealing with securing upload functionality yourself, consider using services such as UploadThing or sending files directly to a cloud object storage service. Uploading files to disk on a server you operate is bad practice.

Conclusion

These general guidelines will help you create a secure application for both you and your users. However, the specific configurations and implementations will need to be tailored to meet the needs of your specific application. There is no one-size-fits-all approach to security.

Subscribe by email

Get the full posts by email every week.