Adding an authenticated state

Adding an authenticated state

Let admins sign in by adding a login route that creates a session cookie.

To let users sign in to our app, let's create a new /login route that has a form:

// routes/login.tsx
import { Form } from "@remix-run/react";

export default function LoginPage() {
  return (
    <div className="mt-8">
      <Form>
        <input
          className="text-gray-900"
          placeholder="email"
          name="email"
          type="email"
        />
        <input
          className="text-gray-900"
          placeholder="password"
          name="password"
          type="password"
        />
        <button>Log in</button>
      </Form>
    </div>
  );
}

If we had an isAdmin variable, we could use it to change the interface based on whether the user is logged in or not. If they are, we can show a message saying they're logged in, otherwise we can render the form:

export default function LoginPage() {
  let isAdmin = false;

  return (
    <div className="mt-8">
      {isAdmin ? (
        <p>You're logged in!</p>
      ) : (
        <Form>
          <input
            className="text-gray-900"
            placeholder="email"
            name="email"
            type="email"
          />
          <input
            className="text-gray-900"
            placeholder="password"
            name="password"
            type="password"
          />
          <button>Log in</button>
        </Form>
      )}
    </div>
  );
}

How can we update isAdmin using the credentials from our form? If we set the form's method to post, we can write an action to handle the form submission:

export async function action({ request }: ActionArgs) {
  let formData = await request.formData();
  let { email, password } = Object.fromEntries(formData);

  if (email === "sam@buildui.com" && password === "password") {
    return { isAdmin: true };
  } else {
    return null;
  }
}

export default function LoginPage() {
  let isAdmin = false;

  return (
    <div className="mt-8">
      {isAdmin ? (
        <p>You're logged in!</p>
      ) : (
        <Form method="post">
          <input
            className="text-gray-900"
            placeholder="email"
            name="email"
            type="email"
          />
          <input
            className="text-gray-900"
            placeholder="password"
            name="password"
            type="password"
          />
          <button>Log in</button>
        </Form>
      )}
    </div>
  );
}

Our action returns { isAdmin: true } if the username and password are valid. To get the return value of our action inside of our Login page component, we can use the useActionData() hook from Remix:

import { Form, useActionData } from "@remix-run/react";

export default function LoginPage() {
  let data = useActionData();

  return (
    <div className="mt-8">
      {data?.isAdmin ? (
        <p>You're logged in!</p>
      ) : (
        <Form method="post">
          <input
            className="text-gray-900"
            placeholder="email"
            name="email"
            type="email"
          />
          <input
            className="text-gray-900"
            placeholder="password"
            name="password"
            type="password"
          />
          <button>Log in</button>
        </Form>
      )}
    </div>
  );
}

Now if we submit the form with valid credentials, our page component re-renders, and we see the welcome message!

We've successfully updated our UI based on the user entering the correct email and password. But there's a problem – if we refresh the site, we see the original logged-out UI. We need something durable that will survive page reloads.

HTTP Sessions and Cookie storage

The solution is to use a Session. An HTTP Session is data that's stored somewhere durable, so that it can be used on subsequent requests. The easiest place to store Session data is by using the browser's built-in Cookie storage.

In our action, instead of returning data when the credentials match, we can create a Cookie that says the user is an admin. We can do that by returning a Response object with a Set-Cookie header:

export async function action({ request }: ActionArgs) {
  let formData = await request.formData();
  let { email, password } = Object.fromEntries(formData);

  if (email === "sam@buildui.com" && password === "password") {
    return new Response("", {
      headers: {
        "Set-Cookie": "admin=1",
      },
    });
  } else {
    return null;
  }
}

When the user submits the form, the browser makes a request and gets back a response with this new header. The browser reads the Set-Cookie header and creates a Cookie with our data of admin=1. We can see the new Cookie if we open up the Application tab in the Chome DevTools. And, if we refresh the page, the Cookie is still there!

Now that we've created a Session with this data, we can update our page to read the Session data when it's loaded in order to render the logged-in version for admins. We can do this by creating a loader and reading the Cookie from the request:

export async function loader({ request }: LoaderArgs) {
  let admin = request.headers.get("cookie")?.split("=")[1];

  return { isAdmin: admin === "1" };
}

We can now access this data in our page using the useLoaderData() hook:

export default function LoginPage() {
  let data = useLoaderData();

  return (
    <div className="mt-8">
      {data.isAdmin ? (
        <p>You're logged in!</p>
      ) : (
        <Form method="post">
          <input
            className="text-gray-900"
            placeholder="email"
            name="email"
            type="email"
          />
          <input
            className="text-gray-900"
            placeholder="password"
            name="password"
            type="password"
          />
          <button>Log in</button>
        </Form>
      )}
    </div>
  );
}

Now if we log in with valid credentials, we see the logged-in message just like before – but if we refresh the page, we still see the message!

The Cookie has become the single source of truth for the admin status, and it survives page reloads thanks to the browser's durable storage.

Using Remix's createCookieSessionStorage() to encode and parse Cookie data

Our code works, but there's a few problems with it. First, we're limited to using strings when setting the Cookie value to admin=1, and we also have some flaky parsing code when reading the cookie in our loader. But second and more importantly, our Cookie is insecure. A user could open their browser and create a new cookie with a name of admin and a value of 1, and see the site as an admin, all without ever entering the correct email and password.

Remix includes a createCookieSessionStorage API that addresses all of these problems. It lets us use JavaScript primitive types like booleans for our Cookie values, takes care of encoding our data into a Base64 string to prevent problems with transmission, and lets us secure our cookie with a secret to prevent users from tampering with it on the client.

Let's start by refactoring our action to use createCookieSessionStorage, so that we can use JavaScript primitives for our Session data instead of a string:

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

export async function action({ request }: ActionArgs) {
  let formData = await request.formData();
  let { email, password } = Object.fromEntries(formData);

  if (email === "sam@buildui.com" && password === "password") {
    let storage = createCookieSessionStorage({
      cookie: {
        name: "work-journal-session",
      },
    });
    let session = await storage.getSession();
    session.set("isAdmin", true);

    return new Response("", {
      headers: {
        "Set-Cookie": await storage.commitSession(session),
      },
    });
  } else {
    return null;
  }
}

We first call createCookieSessionStorage and pass in a name for our Cookie – we'll use work-journal-session. We can then get a session object from the storage by awaiting a call to storage.getSession().

This session object is where we can put new data that will end up in the browser, which we do by calling session.set('isAdmin', true) when the credentials are valid. Now that we've updated the session, we need to commit those updates by calling await storage.commitSession(session). This returns a string value that we can respond with in our Set-Cookie header. The string contains the serialized and Base64-encoded data from our session object, which the browser will then use to update the value in its Cookie storage. If we open the DevTools, we can see the new value being stored:

eyJpc0FkbWluIjp0cnVlfQ==

Now that we're creating our Cookie with createCookieSessionStorage, let's use it in our loader as well, so Remix can handle decoding and parsing the data for us:

export async function loader({ request }: LoaderArgs) {
  let storage = createCookieSessionStorage({
    cookie: {
      name: "work-journal-session",
    },
  });
  let session = await storage.getSession(request.headers.get("cookie"));

  return session.data;
}

Note that we pass in request.headers.get('cookie') into the storage's getSession method, since the loader runs after the Session data has been stored in the browser, and therefore that data should be read from the header on each subsequent request.

Our app behaves just like before, but now Remix is handling the encoding, serialization, and parsing for us.

Extracting our Session to a shared module

Since we always want the action and loader to be working with the same Cookie, we can extract our call to createCookieSessionStorage to a shared module, so we only have a single place to define its options.

Let's move it to a new session.ts module, and export the three methods from the storage object as the interface this module exposes.

// app/session.ts
import { createCookieSessionStorage } from "@remix-run/node";

export const { getSession, commitSession, destroySession } =
  createCookieSessionStorage({
    cookie: {
      name: "work-journal-session",
    },
  });

We can now update our Login page to just use getSession and commitSession directly from this module:

import { commitSession, getSession } from "~/session";

export async function action({ request }: ActionArgs) {
  let formData = await request.formData();
  let { email, password } = Object.fromEntries(formData);

  if (email === "sam@buildui.com" && password === "password") {
    let session = await getSession();
    session.set("isAdmin", true);

    return new Response("", {
      headers: { "Set-Cookie": await commitSession(session) },
    });
  } else {
    return null;
  }
}

export async function loader({ request }: LoaderArgs) {
  let session = await getSession(request.headers.get("Cookie"));

  return session.data;
}

Now if we need to make changes to our Cookie storage, we only need to update our new session.ts module.

Using a secret to secure our Cookie

The createCookieSessionStorage API is serializing and encoding our Session data, but our Cookie is still insecure. A user could open their browser, decode the Cookie, change a value, and then update the Cookie, without providing any credentials at all.

To prevent this ability to tamper with our Cookie data, we can use a secret to sign our Cookie at the time we create it in our action. Then, every time we read it in our loader, we can verify the signature using that secret, and only if the signature matches will we trust that the Cookie data came from our server action.

Remix makes this easy – all we need to do is pass in a secrets key when calling createCookieSessionStorage:

// app/session.ts
import { createCookieSessionStorage } from "@remix-run/node";

export const { getSession, commitSession, destroySession } =
  createCookieSessionStorage({
    cookie: {
      name: "work-journal-session",
      secrets: ["build-ui-secret"],
    },
  });

The secrets key is an array of strings, since you may want to periodically rotate them. Secrets also typically come from ENV variables. But for our case, any string that is not exposed to the client will work to secure our Cookie from tampering.

If we go through the login flow again and look at the Cookie value from the browser, we'll see this:

eyJpc0FkbWluIjp0cnVlfQ==.cBjLlhkkoXE76LIVgfuPQP802/KKUcW7qRIrm/8xv/0

The first part of the string is the same, but now there's a period (.) followed by a new string of text. That new text is the signature that was generated with the secret when we created the cookie in our action. Since we're using the same Cookie and secret in our loader, Remix verifies the signature matches, and only returns the data if it does; otherwise, it will return an empty string.

Our Session data is now secured in a signed Cookie, meaning we can trust that if it says isAdmin is true, the user went through our login flow and provided the correct credentials.

Note: It's a good practice to rotate your production secrets on a regular basis as an added layer of security against accidental leaks or typical business activities like employee turnover. Add new secrets to the front of the secrets array according to a schedule and the newest secret will always be used to sign outgoing cookies.

Finally, let's update our storage with a few more options that are a good practice to use for session cookies:

export const { getSession, commitSession, destroySession } =
  createCookieSessionStorage({
    cookie: {
      name: "work-journal-session",
      secrets: ["build-ui-secret"],

      sameSite: "lax",
      path: "/",
      maxAge: 60 * 60 * 24 * 30,
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
    },
  });

Using our Session to customize the homepage

Now that our basic auth flow is working, let's update it to redirect the user back to the hompage after a successful login. We'll use the redirect helper from Remix:

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

export async function action({ request }: ActionArgs) {
  let formData = await request.formData();
  let { email, password } = Object.fromEntries(formData);

  if (email === "sam@buildui.com" && password === "password") {
    let session = await getSession();
    session.set("isAdmin", true);

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

On our homepage, we can now use our Session to customize the UI based on whether the user is an admin or not. Let's only show the Create Entry form if the user is an admin.

We'll copy the getSession() code to our Index page's loader:

// routes/index.tsx

export async function loader({ request }: LoaderArgs) {
  let session = await getSession(request.headers.get("Cookie"));
  let db = new PrismaClient();
  let entries = await db.entry.findMany();

  return {
    session: session.data,
    entries: entries.map((entry) => ({
      ...entry,
      date: entry.date.toISOString().substring(0, 10),
    })),
  };
}

...and use it to customize our page:

export default function Index() {
  let { session, entries } = useLoaderData<typeof loader>();

  // ...

  return (
    <div>
      {session.isAdmin && (
        <div className="my-8 border p-3">
          <p className="italic">Create a new entry</p>

          <EntryForm />
        </div>
      )}

      {/* ... */}
    </div>
  );
}

Now if we clear our Cookies and visit the homepage, we don't see the Create Entry form. But if we visit /login, and log in with the correct email and password, we're redirected back home, and we see the form. We can also refresh the site and continue to use the app as an admin.

We're now in a place where we can continue to use our secure Session data to customize both the UI, as well as what actions a user can take, based on their authentication status.

Buy Ship an app with Remix

Buy the course

$199
one-time payment

Get everything in Ship an app with Remix.

  • 5+ hours of video
  • 19 lessons
  • Private Discord
  • Summaries with code
  • Unlimited access to course materials

Lifetime membership

$349
Access all coursesone-time payment

Get lifetime access to every Build UI course, including Ship an app with Remix, forever.

  • Access to all five Build UI courses
  • Summaries with code
  • Video downloads
  • Working code demos
  • Private Discord
  • Priority support

What's included

Stream or download every video

Watch every lesson directly on Build UI, or download them to watch offline at any time.

Live code demos

Access to a live demo of each lesson that runs directly in your browser.

Private Discord

Chat with Sam, Ryan and other Build UI members about the lessons – or anything else you're working on – in our private server.

Video summaries with code snippets

Quickly reference a lesson's material with text summaries and copyable code snippets.

Source code

Each lesson comes with a GitHub repo that includes a diff of the source code.

Invoices and receipts

Get reimbursed from your employer for becoming a better coder!