Sergio Xalambrí

Adding CSRF protection to Remix

While you may not need CSRF if your cookies have the SameSite: Lax configured, it may still be a good idea to add it or you may need to due security requirements. Let's see how CSRF protection works and how to add it.

The whole idea of a CSRF tokens is that you can generate on each GET request a new random token, you store it somewhere (it could be a session cookie) and then you can send it inside each form when doing a POST request, if the token coming on the form and the one stored in the cookie are different then you reject the request and return a four hundred something status code, but if they match you continue with the logic.

This is useful to avoid third-party servers to send a POST request against your application and do things. Let's implement it.

Create and validate the CSRF token

The first thing we need to do is to be able to create easily a new CSRF token and validate it. To create a new token we can use anything that generates a random string, in our case we will use the randomBytes function from the crypto module of Node.

import { randomBytes } from "crypto";
export function createCSRFToken() {
  return randomBytes(100).toString("base64");
}

Now we need to have a way to validate the token is on the session, is on the body of the request and they are the same.

import type { Request, Session } from "remix";
import { bodyParser } from "./body-parser.server"; // this is a function to parse the body as JSON

export async function validateCSRFToken(request: Request, session: Session) {
  // first we parse the body, be sure to clone the request so you can parse the body again in the future
  let body = Object.fromEntries(
    new URLSearchParams(await request.clone().text()).entries()
  ) as { csrf?: string };
  // then we throw an error if one of our validations didn't pass
  if (!session.has("csrf")) throw new Error("CSRF Token not included.");
  if (!body.csrf) throw new Error("CSRF Token not included.");
  if (body.csrf !== session.get("csrf"))
    throw new Error("CSRF tokens do not match.");
  // we don't need to return anything, if the validation fail it will throw an error
}

Use it in Remix

Now we need to generate a new CSRF token on our loaders, save it in the session and send it in the body.

export let loader: LoaderFunction = async ({ request }) => {
  // get the session
  let session = await getSession(request.headers.get("Cookie"));
  // generate the token
  let csrf = createCSRFToken();
  // save the token in the session
  session.set("csrf", csrf);
  // send the token in the body and send the Set-Cookie header to commit the session
  return json(
    { csrf },
    { headers: { "Set-Cookie": await commitSession(session) } }
  );
};

In the component of our route we will have a form and inside this form we need to add the token.

export default function View() {
  let { csrf } = useRouteData();

  return (
    <form method="post">
      <input type="hidden" name="csrf" value={csrf} />
      {/* More form fields here */}
      <button type="submit">Submit</button>
    </form>
  );
}

And finally, call we call the validateCSRFToken in the action before running the rest of our code.

export let action: ActionFunction = async ({ request }) => {
  // we get the session
  let session = await getSession(request.headers.get("Cookie"));

  try {
    await validateCSRFToken(request, session);
  } catch (error) {
    // if the validation fail we redirect the user to another URL and set the error as a flash message
    session.flash("error", error.message);
    return redirect("/some-path", {
      headers: { "Set-Cookie": await commitSession(session) },
    });
  }

  // here you can run the actual action code and do anything you want, this part of the code will be safe
};

With a few lines of code we were able to add CSRF token to Remix, you may want to move the generation of the CSRF token to the root.tsx file so you only generate it once per GET request and only on the first request, after each POST the loader will run again and it will be updated, but while navigating the page the CSRF token will remain intact.