Cookie-based Authentication in Remix

Cookie-based Authentication in Remix

We dive into cookies, remix source code, specs, hashing and much more!

Featured on Hashnode

Introduction

In this blog post, I wanna go over cookies and how to implement cookie-based authentication in React Router 7 (as a framework).

Yes, the title uses Remix since most are familiar with it. The team has now rebranded it to React Router 7. Which you now use as a library or framework.

I don't assume you're familiar with cookies. We'll start from the basics and move up from there.

I'm a big fan of learning from first principles. We'll dig into source code and specs. It's gonna be a lot of fun.

A Contagious Smile : r/Naruto

I recommend reading MDN and documentation along the way. Read it in detail. Take your time. Make sure you understand things. Don't just read this post and "think" I covered everything.

My mindset to learning to is to be the "owner" of how I consume information. Which is why you'll see me digging into source code, specs, etc.

Remember, nothing is magic. You can learn everything. That fancy library or framework you see, whether it’s written in Rust, TypeScript, Zig, you name it, a human being wrote it.

What are cookies?

Cookies are small pieces of data stored as text strings in the browser. The fundamental format is name=value. They can have additional attributes, separated by semicolons.

When a server wants to set a cookie, it sends an HTTP response header: Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict

The browser then stores this cookie and automatically attaches (link to spec) it to future requests to the same domain in the Cookie header: Cookie: session=abc123.

The automatic attachment by browsers is important. It's why cookies are good for authentication. You don't need to manually handle this in your client side code.

HttpOnly

JavaScript can't access the cookie, preventing XSS attacks. XSS stands for Cross Site Scripting, it's when an attacker injects malicious code into a web page.

Secure

Cookie only sent over HTTPS. HTTPS is the secure version of HTTP which uses TLS encryption under the hood.

SameSite

Controls when cookie is sent in cross-site requests. This prevents CSRF attacks. A CSRF attack is when an attacker tricks a user into making a POST request to a website without their knowledge.

For example, if you get an email with a button that says "click here to view your account", and you click it, the browser will automatically send the cookie with the request. The attacker can then use this to access the user's account.

It's common to set this value to SameSite=Lax to allow cookies to be sent in cross-site requests when GET requests are made. This way, they can remain logged in to the site.

Domain

Which domains can receive the cookie. This is useful if you want to share cookies between subdomains.

Path

Which paths on the domain receive the cookie.

Expires/Max-Age

When the cookie should be deleted.

Cookies in React Router 7 (as a framework)

In React Router 7, there are two primary ways to handle cookies: createCookie and createCookieSessionStorage.

The full documentation for both can be found here: React Router 7 - Sessions and Cookies.

My goal here isn't to restate everything in the docs. But I do want to go over a few things in a clearer way.

createCookie

The source code for createCookie can be found here: cookies.ts - Line 73.

createCookie is the fundamental building block for dealing with cookies.

  • Creates a single cookie with name/value

  • Handles raw cookie operations (parse/serialize)

  • Manages cookie attributes (expires, path, etc.)

  • Can sign cookies for security

  • Just manages ONE single key-value pair in the cookie

When is it useful?

  • Simple preferences (theme, language)

  • Single pieces of data

Here is a code example:

// createCookie - theme preference
const themeCookie = createCookie("theme", {
  maxAge: 604_800, // one week
  path: "/",
});

// Usage
response.headers.set("Set-Cookie", await themeCookie.serialize("dark"));

What is a "session"?

In the documentation, createCookieSessionStorage is described to provide a session-like interface. For someone who isn't familiar with the concept of a session, this can be confusing.

In traditional web development (e.g. with PHP), a session refers to a server side storage of data that persists across requests. For example, when you log into a PHP app, it creates a session file on the server with your data, and sends you (the client) a session ID cookie. The server uses this ID to find your data.

So it's gonna map the id to the data. On the server, the data can be stored in a database or file system. Files on disk is PHP's default.

createCookieSessionStorage

The source code for createCookieSessionStorage can be found here: cookieStorage.ts - Line 26.

createCookieSessionStorage uses createCookie internally. It stores all your data in one cookie. That's how it is different from the traditional session approach. Now, if you want, you can create a custom session storage.

Here is an example of how to use it from the documentation:

import { createCookieSessionStorage } from "react-router";

type SessionData = {
  userId: string;
};

type SessionFlashData = {
  error: string;
};

const { getSession, commitSession, destroySession } =
  createCookieSessionStorage<SessionData, SessionFlashData>(
    {
      // a Cookie from `createCookie` or the CookieOptions to create one
      cookie: {
        name: "__session",

        // all of these are optional
        domain: "reactrouter.com",
        // Expires can also be set (although maxAge overrides it when used in combination).
        // Note that this method is NOT recommended as `new Date` creates only one date on each server deployment, not a dynamic date in the future!
        //
        // expires: new Date(Date.now() + 60_000),
        httpOnly: true,
        maxAge: 60,
        path: "/",
        sameSite: "lax",
        secrets: ["s3cret1"],
        secure: true,
      },
    }
  );

export { getSession, commitSession, destroySession };

We touched on cookie options already e.g. name, maxAge, path, etc. If you want the source code for the types that React Router 7 uses, you can find it here.

Let's look at two quick snippets. One that demonstrates persisting userId and the other one shows using flash messages.

Auth example

This is just a quick example. I don't wanna dig too deep since I'm doing things a bit differently in my own project to keep it type safe.

const { getSession, commitSession } = createCookieSessionStorage({
  cookie: {
    name: "auth",
    secrets: ["your-secret"],
    secure: true,
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 30, // 30 days
  },
});

// in your action
async function login(request: Request) {
  const session = await getSession(request.headers.get("Cookie"));

  // here you'd get the userId from from the database
  // im just hardcoding it as demonstration
  session.set("userId", "123");

  return redirect("/dashboard", {
    headers: {
      // you always need to commit the session
      // otherwise the cookie won't be set in the headers
      // ...and the changes only remain in memory
      "Set-Cookie": await commitSession(session),
    },
  });
}

// Auth check in loaders
async function checkAuth(request: Request) {
  const session = await getSession(request.headers.get("Cookie"));
  const userId = session.get("userId");

  if (!userId) {
    return redirect("/login");
  }

  // ...
}

commitSession quickly explained

commitSession can be a bit of a confusion. You may wonder why changes aren't set after calling session.set().

In the source code, you'll see that commitSession is what actually sets the cookie in the headers:

    async commitSession(session, options) {
      let serializedCookie = await cookie.serialize(session.data, options);
      if (serializedCookie.length > 4096) {
        throw new Error(
          "Cookie length will exceed browser maximum. Length: " +
            serializedCookie.length
        );
      }
      return serializedCookie;
    }

Flash messages example

const { getSession, commitSession } = createCookieSessionStorage({
  cookie: {
    // can be any name you want e.g. toast
    name: "flash",
    secrets: ["your-secret"],
    sameSite: "lax",
  },
});

// Action that sets flash
async function saveAction(request: Request) {
  const session = await getSession(request.headers.get("Cookie"));
  session.flash("success", "Changes saved successfully!");

  return redirect("/dashboard", {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
}

// Loader that reads flash
async function loader(request: Request) {
  const session = await getSession(request.headers.get("Cookie"));
  // Flash messages are removed after being read
  const message = session.get("success");

  return json(
    { message },
    {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    }
  );
}

Authentication in my side project

Now it's time for the exciting stuff hihi

I wanna share how I did authentication in my side project. We're gonna cover password based authentication.

Now, I was using Prisma as ORM and Prisma Postgres as database. You can use whatever you want though. That's just how we store data.

I'll first talk about how I deal with the cookies & redirects, then later we can look at the password stuff.

Cookies & redirects

I'm using createCookieSessionStorage to handle the cookies. The relevant file for this is under auth.server.ts.

I like to keep things type safe, so under the constants.ts file, you'll see how I deal with the cookie strings and routes:

export const ROUTES = {
  home: '/',
  roomDetail: '/rooms/:roomCode',
  roomJoin: '/rooms/:roomCode/join',
  leaveRoom: '/rooms/:roomCode/leave',
  login: '/login',
  register: '/register',
} as const

export const COOKIE_KEYS = {
  setCookie: 'Set-Cookie',
  getCookie: 'Cookie',
} as const

For routes, you may wonder, how do you create a route you need to redirect to? I use generatePath React Router. It also infers what you should pass as params. An example: redirect(generatePath(ROUTES.roomDetail, { roomCode: userRoom.code })).

The cookie keys are there so that I don't ever mistype them. If you're working on a large project in a team, these are VERY helpful.

The keys for setting and getting cookies are different. It's a bit confusing that the get one is just "Cookie", but the set one is "Set-Cookie". But that's how it is I guess.

First bit

Let's go over the first bit of auth.server.ts:

const authCookie: SessionIdStorageStrategy["cookie"] = {
  name: "__session",
  secrets: [serverEnv.SESSION_SECRET],
  sameSite: "lax",
  maxAge: 60 * 60 * 24 * 30, // 30 days
  httpOnly: true,
  secure: serverEnv.NODE_ENV === "production",
};

const cookieSchema = z.object({
  userId: z.string().optional(),
});

const sessionStorage = createCookieSessionStorage({ cookie: authCookie });

const typedAuthSessionStorage = createTypedSessionStorage({
  sessionStorage,
  schema: cookieSchema,
});

export function getCookieFromRequest(request: Request) {
  return request.headers.get(COOKIE_KEYS.getCookie);
}

Here I'm using createTypedSessionStorage from remix-utils. It's a library that provides helpers when working with React Router 7. In this case, it allows you pass a zod schema to the session storage. Keeping things type safe.

The reason userId is optional is because it could also not be there at all e.g. if user is not logged in. SessionIdStorageStrategy is just a type from React Router, where we access the cookie definition.

getCookieFromRequest is a helper function that gets the cookie from the request headers.

Requests and responses by the way are the normal requests and responses. It's not anything specific to React Router 7. I mean, it's built on top of the web platform after all.

requireAuth and logout

type AuthResponse =
  | {
      type: "redirect";
      response: Response;
    }
  | {
      type: "result";
      user: User;
    };

export async function requireAuth({
  request,
}: {
  request: Request;
}): Promise<AuthResponse> {
  const session = await typedAuthSessionStorage.getSession(
    getCookieFromRequest(request)
  );
  const userId = session.get("userId");

  if (!userId) {
    return {
      type: "redirect",
      response: redirect(generatePath(ROUTES.login)),
    };
  }

  const user = await prisma.user.findUnique({
    where: { id: userId },
  });

  // if user doesn't exist for whatever reason
  // logout
  if (!user) {
    return {
      type: "redirect",
      response: await logout({ request }),
    };
  }

  return {
    type: "result",
    user,
  };
}

export async function logout({ request }: { request: Request }) {
  const session = await typedAuthSessionStorage.getSession(
    getCookieFromRequest(request)
  );

  const headers = new Headers();
  headers.set(
    COOKIE_KEYS.setCookie,
    await typedAuthSessionStorage.destroySession(session)
  );

  return redirect(generatePath(ROUTES.login), {
    headers,
  });
}

// How I use requireAuth
// Can be found under e.g. app/routes/resources/api.liveblocks.ts
const requireAuthResult = await requireAuth({ request });

if (requireAuthResult.type === "redirect") return requireAuthResult.response;

const { user } = requireAuthResult;

Now, you can do it differently if you want. This is how I prefer to do it. And yes, it's annoying that middleware doesn't exist in React Router 7. It should be out in 2-3 months hopefully. For now, we need to handle every route ourselves.

requireAuth returns either a redirect or a user. To be very clear, redirect under the hood is just a Response. A redirect itself is a 3XX response. Source code: utils.ts.

By default, it is a 302 response. Which means a temporary redirect. This tells the browser to not cache the redirect permanently. A 301 response would be a permanent redirect, which tells the browser to cache the redirect permanently. The problem there is that if the user logs out and logs in as a different user, the browser will serve a cached redirect to the previous user's destination. Which is not what you want. 301 is suitable for e.g. static assets because they don't change.


Going back to the code:

type AuthResponse =
  | {
      type: "redirect";
      response: Response;
    }
  | {
      type: "result";
      user: User;
    };

requireAuth returns one of the two types.

If the type is a redirect, we know to just return the response we get. If not, we know the user is authenticated and we can continue with the user object.


Let's take a look at two things in the logout function.

The first thing is we destroy the session. Under the hood, this is simply serializing the cookie with an empty string and making sure it expires immediately (source code).

The way I use Headers is something I plan on doing more often. I just find it cleaner. That's nothing from React Router 7. It's a web API. See MDN.

Password based authentication

You can use popular libraries like bcrypt. I'm using the crypto library from node.js and doing it myself.

The key file to how I handle password based authentication:

import crypto from 'crypto'

export class PasswordService {
  private static ITERATIONS = 1000
  private static KEY_LENGTH = 64
  private static ALGORITHM = 'sha256'

  static async hashPassword(password: string) {
    const salt = crypto.randomBytes(16).toString('hex')
    const hash = await this.generateHash({ password, salt })
    return { hash, salt }
  }

  static async verifyPassword({
    password,
    storedHash,
    storedSalt,
  }: {
    password: string
    storedHash: string
    storedSalt: string
  }) {
    const attemptHash = await this.generateHash({ password, salt: storedSalt })
    return attemptHash === storedHash
  }

  private static generateHash({
    password,
    salt,
  }: {
    password: string
    salt: string
  }) {
    return new Promise<string>((resolve, reject) => {
      crypto.pbkdf2(
        password,
        salt,
        this.ITERATIONS,
        this.KEY_LENGTH,
        this.ALGORITHM,
        (err, key) => {
          if (err) reject(err)
          resolve(key.toString('hex'))
        }
      )
    })
  }
}

I use this in:

You may look at the code and go, wow, what's going on here?

Let's go over it!

Storing passwords safely

We need a way to verify if a user's login password matches what they used when signing up. This means we need to store something. But we can't store the raw password. Because if your database gets leaked, then the attacker can get the passwords.

What we need is:

  1. A function that always produces the same output for the same input (deterministic).

  2. But is impossible to reverse - you can't go from output back to input.

  3. So even if attackers get our database, they can't get passwords!

This is what a hash function does. Every time you input "password123", you get the same weird output like "a32ef7...". But you can't go backwards. That's the beauty of it.

However, simple hashing isn't enough:

  1. Attackers can pre-compute hashes for common passwords.

  2. If two users have password "123456", they'll have identical hashes (this is a problem!).

  3. Modern computers can try billions of hashes per second.

This is where PBKDF2 (Password-Based Key Derivation Function 2) comes in. It:

  1. Adds a unique random value (salt) to each password before hashing

    • So ("password123" + "RANDOM1") ≠ ("password123" + "RANDOM2")

    • This is why we use randomBytes(16) to create a unique salt

  2. Hashes the result thousands of times

    • Each password guess now requires 1000 hashes

    • Makes brute force attacks thousands of times slower

    • This is our constant ITERATIONS = 1000

    • To be clear, after hashing the first time, we take that result and hash again. This is what we keep on repeating for each hash output.

When a user signs up:

  1. Generate random salt

  2. Run PBKDF2(password + salt) 1000 times

  3. Store the result and salt

When they login:

  1. Get salt from database

  2. Run PBKDF2(login_attempt + salt) 1000 times

  3. If result matches stored hash, password is correct (remember, it is deterministic!)

PBKDF2 parameters

PBKDF2 is recommended by NIST. You can find this publication here.


For ITERATIONS, a minimum of 1000 is recommended. This is what we use. On page 11 of the paper, it says:

A minimum iteration count of 1,000 is recommended


On page 10, you'll see:

All or a portion of the salt shall be generated using an approved Random Bit Generator (e.g., see [5]). The length of the randomly-generated portion of the salt shall be at least 128 bits.

When we do crypto.randomBytes(16), we get 16 bytes. That's 128 bits.


ALGORITHM (SHA-256) is the specific hash function PBKDF2 uses for each iteration. SHA-256 is a cryptographically secure hash function that produces 256-bit outputs, and is widely trusted by the security community.


KEY_LENGTH (64 bytes = 512 bits) determines how long the final hash will be.

This is a bit tricky to understand. I spent like an hour researching and trying to understand what key length is actually doing here lol.

KEY_LENGTH serves two purposes:

  1. It determines the length of our final output hash

    • In our code it's 64 bytes (512 bits).

    • PBKDF2 can create this longer output by running multiple times and merging results. Otherwise, each hash output is 32 bytes (256 bits), because that's the output of SHA-256.

  2. There's a catch though:

    • Because we're using SHA-256, the real security strength is limited to 256 bits.

    • Making it longer than 256 bits (32 bytes) does NOT add more security.

    • This is because HMAC-SHA256 internally works with 256-bit chunks.

We can ask for longer outputs, the actual security we get is the same as 256 bits. The real security comes from:

  • Using a good salt

  • Having enough iterations

  • Users choosing strong passwords

Conclusion

We went through a lot here. Way more than I expected lmao. It took me 5 hours to write this. I’m a bit stubborn when it comes to really understanding how things work.

If a single takeaway from this post, it’s gotta be the quote from John Carmack → “Weaponize your curiosity”

Pin page