DEV Community

Cover image for Secure Your Remix.js App: A Guide to Authentication and Authorization
Francisco Mendes
Francisco Mendes

Posted on

Secure Your Remix.js App: A Guide to Authentication and Authorization

What you will learn

I hope that after reading this article you will have enough foundations to add any authentication and authorization strategy in Remix applications.

final result

In addition, we will also create domain functions, database schema definition, form handling, among other topics.

What does this article cover

We will cover several aspects, including:

  • Tailwind Configuration
  • Form validation
  • Database schema modeling
  • Authentication and authorization

Prerequisites

Before starting the article, it is recommended that you have knowledge of React, Remix, Tailwind and a prior knowledge of nested routes and ORM's.

Creating the Project

To initialize a project in Remix we execute the following command:

npx create-remix@latest remizzle
cd remizzle
Enter fullscreen mode Exit fullscreen mode

We start the dev server with the following command:

npm run dev
Enter fullscreen mode Exit fullscreen mode

We are going to use the Just the basics type, with a deployment target of Remix App Server and we are going to use TypeScript in the application.

Set up Tailwind

Next, let's configure Tailwind in our project, starting with the installation:

npm install -D tailwindcss
npx tailwindcss init --ts
Enter fullscreen mode Exit fullscreen mode

Then we add the following in the remix.config.js file:

module.exports = {
  future: {
    tailwind: true,
  },
};
Enter fullscreen mode Exit fullscreen mode

We also make the following changes to tailwind.config.ts:

import type { Config } from "tailwindcss";

export default {
  content: ["./app/**/*.{js,jsx,ts,tsx}"],
  // ...
} satisfies Config
Enter fullscreen mode Exit fullscreen mode

Next, we create a file called tailwind.css inside the app/ folder with the following:

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

Now let's import the newly created css file in root.tsx:

import { cssBundleHref } from "@remix-run/css-bundle";
// ...

import stylesheet from "~/styles/tailwind.css";

export const links: LinksFunction = () => [
  ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
  { rel: "stylesheet", href: stylesheet },
];

// ...
Enter fullscreen mode Exit fullscreen mode

To make app development easier, let's take advantage of the pre-styled components of the Flowbite library, starting by installing the dependency:

npm install flowbite
Enter fullscreen mode Exit fullscreen mode

Going back to tailwind.config.ts we add the flowbite plugin in the configuration:

import type { Config } from "tailwindcss";

export default {
  // ...
  plugins: [require("flowbite/plugin")],
} satisfies Config;
Enter fullscreen mode Exit fullscreen mode

Set up Routes Gen

First of all, let's define each of our application's routes, taking into account that theoretically we'll have two layouts, one for the authentication pages and another for the protected routes.

Another very important point is that the app's root route (/) must redirect the user taking into account their status, if they are logged in they go to protected routes, otherwise they go to authentication.

Now that we have a small overview, we can define the following routes:

  • _index.tsx - app main route
  • auth.tsx - authentication page(s) layout
  • auth.sign-in.tsx - page where we are going to log in
  • auth.sign-up.tsx - page where we will create a new account
  • protected.tsx - protected page(s) layout, and we are going to add protection to ensure that you can only access it if you have a session
  • protected._index.tsx - main route of the protected pages and in this article it is where we will see the user information and we will log out

With the above routes created inside the routes/ folder, we can install the following dependencies:

npm install routes-gen @routes-gen/remix --dev 
Enter fullscreen mode Exit fullscreen mode

And in the package.json file we add the following script:

{
  // ...
  "scripts": {
    // ...
    "routes:gen": "routes-gen -d @routes-gen/remix"
  },
  // ...
}
Enter fullscreen mode Exit fullscreen mode

When we run the command, it will create the routes.d.ts file inside the app/ folder with the data type declarations taking into account the application's routes. Remembering that the command is this:

npm run routes:gen
Enter fullscreen mode Exit fullscreen mode

Set up Drizzle ORM

To make this article accessible to more people, we are going to use the SQLite database and install the following dependencies:

npm install drizzle-orm better-sqlite3
npm install -D drizzle-kit @types/better-sqlite3
Enter fullscreen mode Exit fullscreen mode

The next step will be to create the config.server.ts file in a folder called db/ inside the app/ folder, with the following:

// @/app/db/config.server.ts
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import Database from "better-sqlite3";

const sqlite = new Database("sqlite.db");

export const db = drizzle(sqlite);

migrate(db, { migrationsFolder: "./app/db/migrations" });
Enter fullscreen mode Exit fullscreen mode

As you may have noticed in the code block above, we start by defining the database, then we use the SQLite driver and export its instance (db).

Shortly afterwards we used the migrator function to carry out automatic migrations in our database taking into account what is generated inside the app/db/migrations/ folder.

Then we define our database tables in the schema.server.ts file, also inside the db/ folder, taking advantage of drizzle primitives and exporting the schema that was created:

// @/app/db/schema.server.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";

export const users = sqliteTable("users", {
  id: integer("id").primaryKey(),
  username: text("username").notNull(),
  email: text("email").notNull(),
  password: text("password").notNull(),
  createdAt: integer("created_at", { mode: "timestamp" }),
});
Enter fullscreen mode Exit fullscreen mode

The next step is to add the script responsible for generating the database migrations to the package.json file:

{
  // ...
  "scripts": {
    // ...
    "db:migrations": "drizzle-kit generate:sqlite --out ./app/db/migrations --schema ./app/db/schema.server.ts"
  },
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Running the above command will generate migrations inside the app/db/migrations folder that can be applied to the database. With this we can go to the next section.

Domain Functions

In this section of the article we will define the domain functions responsible for registering a new user, as well as starting a new session.

The first step will be to add the following dependencies:

npm install zod domain-functions argon2
Enter fullscreen mode Exit fullscreen mode

Next, inside the app/ folder, we'll create a folder called common/ and inside this an authSchema.ts file. This file will specify some schemas that will be used in form validation and domain functions.

// @/app/common/authSchema.ts
import { z } from "zod";

export const authSchema = z.object({
  username: z
    .string()
    .min(3, "Must contain at least 3 chars")
    .max(24, "Must contain 24 chars max"),
  email: z.string().email(),
  password: z
    .string()
    .min(6, "Must contain at least 6 chars")
    .max(12, "Must contain 12 chars max"),
});

export type RegisterAccountAuth = z.infer<typeof authSchema>;

export const authSchemaWithoutUsername = authSchema.omit({ username: true });

export type GetUserByEmailAuth = z.infer<typeof authSchemaWithoutUsername>;
Enter fullscreen mode Exit fullscreen mode

With the schemas created, still inside the app/ folder, let's create a new domain/ folder with an account.server.ts file. Inside this file we will import the instance of our database, the database schema and the authentication schemas.

Then we create the createAccount function, which will check if there is an account on the platform with the same credentials, if not, we will hash the password and save the user in the database.

Another function that we are going to create is getAccountByEmail, which will check if the user exists, if it exists, we will check if the password is correct and if it is, then the credentials are valid.

// @/app/domain/account.server.ts
import { InputError, makeDomainFunction } from "domain-functions";
import type { InferModel } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { hash, verify } from "argon2";

import { authSchema, authSchemaWithoutUsername } from "~/common/authSchema";
import { db } from "~/db/config.server";
import { users } from "~/db/schema.server";

export const createAccount = makeDomainFunction(authSchema)(async (data) => {
  const result = db
    .select()
    .from(users)
    .where(eq(users.email, data.email))
    .get();

  if (result) {
    throw new InputError("Email already taken", "email");
  }

  const { password, ...rest } = data;

  const hashedPassword = await hash(password);

  const newUser: InferModel<typeof users, "insert"> = {
    ...rest,
    password: hashedPassword,
    createdAt: new Date(),
  };

  const record = db.insert(users).values(newUser).returning().get();

  if (!record || !record.id) {
    throw new Error("Unable to register a new user");
  }

  return record;
});

export const getAccountByEmail = makeDomainFunction(authSchemaWithoutUsername)(
  async (data) => {
    const result = db
      .select()
      .from(users)
      .where(eq(users.email, data.email))
      .get();

    if (!result || !result.email) {
      throw new InputError("Email does not exist", "email");
    }

    const isValidPassword = await verify(result.password, data.password);

    if (!isValidPassword) {
      throw new InputError("Password is not valid", "password");
    }

    return result;
  }
);
Enter fullscreen mode Exit fullscreen mode

With the domain functions completed, we can move on to the next point.

Reusable components

Before moving directly to validating the forms, we need to create the components that we are going to reuse in them, I am talking about the Input component and the Button.

First we need to install the following dependencies:

npm install remix-validated-form @remix-validated-form/with-zod
Enter fullscreen mode Exit fullscreen mode

Starting by creating the Input component, we can do the following:

// @/app/components/Input.ts
import type { InputHTMLAttributes } from "react";
import { useField } from "remix-validated-form";

interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  name: string;
  label: string;
}

export function Input({ name, label, ...rest }: InputProps) {
  const { error, getInputProps } = useField(name);
  return (
    <div className="space-y-2">
      <label htmlFor={name}>{label}</label>

      <input
        className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
        {...getInputProps({ id: name })}
        {...rest}
      />

      {error && (
        <p className="text-sm text-red-600 dark:text-red-500">{error}</p>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now we can create the Button component, in this case we will not take into account the submission state, but this can be added in the future.

// @/app/components/Button.ts
import type { ButtonHTMLAttributes } from "react";

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  label: string;
}

export function Button({ label, ...props }: ButtonProps) {
  return (
    <button
      className="text-white bg-gradient-to-r from-blue-500 via-blue-600 to-blue-700 hover:bg-gradient-to-br focus:ring-4 focus:outline-none focus:ring-blue-300 dark:focus:ring-blue-800 shadow-lg shadow-blue-500/50 dark:shadow-lg dark:shadow-blue-800/80 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
      {...props}
    >
      {label}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

With all the components created, we can move on to the next point.

Authenticator setup

In this article we are not going to use any third-party service, we are going to build the application system ourselves but to facilitate the whole process we are going to use the Remix Auth project. The strategy that will be used in this article is the FormStrategy, but there are several strategies created by the community, accessing this link.

The first step is to install the following dependencies:

npm install remix-auth remix-auth-form
Enter fullscreen mode Exit fullscreen mode

The next step is to create the storage where we are going to save our session, for that we are going to take advantage of the createCookieSessionStorage remix primitive and we are going to add the following configuration in the storage.server.ts file in the auth/ folder:

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

export const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: "_session",
    sameSite: "lax",
    path: "/",
    httpOnly: true,
    secrets: ["super-secret"],
    secure: process.env.NODE_ENV === "production",
  },
});

export const { getSession, commitSession, destroySession } = sessionStorage;
Enter fullscreen mode Exit fullscreen mode

With the storage session created, we can now configure our strategy, starting by importing the storage solution that was created, as well as the domain function responsible for logging in the user.

When defining the authentication strategy, we check if the context has the formData from the login form, if it does, we'll get the email and the password that we'll pass later on in the getAccountByEmail function arguments. If the account exists and the credentials are correct, we will get the username and user identifier to add to the session.

// @/app/auth/authenticator.server.ts
import { Authenticator } from "remix-auth";
import { FormStrategy } from "remix-auth-form";

import { sessionStorage } from "~/auth/storage.server";
import { getAccountByEmail } from "~/domain/account.server";

interface User {
  userId: number;
  username: string;
}

export const EMAIL_PASSWORD_STRATEGY = "email-password-strategy";

export const authenticator = new Authenticator<User>(sessionStorage);

authenticator.use(
  new FormStrategy(async ({ context }) => {
    if (!context?.formData) {
      throw new Error("FormData must be provided in the Context");
    }

    const formData = context.formData as FormData;

    const email = formData.get("email");
    const password = formData.get("password");

    const result = await getAccountByEmail({ email, password });

    if (!result.success) {
      throw new Error("Failed to authenticate user");
    }

    const { username, id } = result.data;

    return { username, userId: id };
  }),
  EMAIL_PASSWORD_STRATEGY
);
Enter fullscreen mode Exit fullscreen mode

With the authenticator defined and everything configured we can move on to the next point.

Creating the app pages

Let's start with the application's main route, in which we'll take advantage of the loader primitive together with the authenticator to redirect the user according to the authentication status.

// @/app/routes/_index.tsx
import { type LoaderFunction } from "@remix-run/node";
import { route } from "routes-gen";

import { authenticator } from "~/auth/authenticator.server";

export const loader: LoaderFunction = async ({ request }) => {
  return await authenticator.isAuthenticated(request, {
    successRedirect: route("/protected"),
    failureRedirect: route("/auth/sign-up"),
  });
};
Enter fullscreen mode Exit fullscreen mode

Next, we can move on to defining the app's layouts, starting with the authentication layout, which will only have the Outlet primitive to render the nested routes.

// @/app/routes/auth.tsx
import { type V2_MetaFunction } from "@remix-run/node";
import { Outlet } from "@remix-run/react";

export const meta: V2_MetaFunction = () => {
  return [{ title: "Auth Pages" }];
};

export default function AuthLayout() {
  return <Outlet />;
}
Enter fullscreen mode Exit fullscreen mode

The next and last layout to be defined is that of protected pages, in which we are going to take advantage of the loader primitive and the authenticator to verify that the user has a valid session, if not, he is redirected to the login page. In the component's JSX we render a small component with the initials of the user's name and the Outlet primitive if the session is valid.

// @/app/routes/protected.tsx
import type { LoaderArgs, V2_MetaFunction } from "@remix-run/node";
import { Outlet, useLoaderData } from "@remix-run/react";
import { route } from "routes-gen";

import { authenticator } from "~/auth/authenticator.server";

export const meta: V2_MetaFunction = () => {
  return [{ title: "Protected Pages" }];
};

export const loader = async ({ request }: LoaderArgs) => {
  return await authenticator.isAuthenticated(request, {
    failureRedirect: route("/auth/sign-in"),
  });
};

export default function ProtectedLayout() {
  const data = useLoaderData<typeof loader>();

  return (
    <div className="m-12">
      <div className="relative inline-flex items-center justify-center w-10 h-10 overflow-hidden bg-gray-100 rounded-full dark:bg-gray-600">
        <span className="font-medium text-gray-600 dark:text-gray-300">
          {data.username[0].toUpperCase()}
        </span>
      </div>

      <Outlet />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Moving now to the creation of the pages themselves and so that the article is not too long, I will give a quick overview of each one.

Starting with the page for creating a new account, let's use the loader primitive to return the initial state of the form that will be managed using Remix Validated Form. When the form is submitted, we validate the integrity of the data and, if valid, we pass it in the arguments of the createAccount function. If successful, we proceed to create a user session using the authenticator.

// @/app/routes/auth.sign-up.tsx
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { withZod } from "@remix-validated-form/with-zod";
import { ValidatedForm, validationError } from "remix-validated-form";
import { Link, useLoaderData } from "@remix-run/react";
import { route } from "routes-gen";

import { Input } from "~/components/Input";
import { Button } from "~/components/Button";
import type { RegisterAccountAuth } from "~/common/authSchema";
import { authSchema } from "~/common/authSchema";
import { createAccount } from "~/domain/account.server";
import {
  EMAIL_PASSWORD_STRATEGY,
  authenticator,
} from "~/auth/authenticator.server";

const validator = withZod(authSchema);

export const loader: LoaderFunction = () => {
  const defaultValues: RegisterAccountAuth = {
    username: "",
    email: "",
    password: "",
  };
  return json({ defaultValues });
};

export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();

  const fieldValues = await validator.validate(formData);
  if (fieldValues.error) return validationError(fieldValues.error);

  const result = await createAccount(fieldValues.data);

  if (!result || !result.success) return null;

  return await authenticator.authenticate(EMAIL_PASSWORD_STRATEGY, request, {
    successRedirect: route("/protected"),
    context: { formData },
  });
};

export default function SignupPage() {
  const { defaultValues } = useLoaderData<typeof loader>();

  return (
    <div className="w-screen h-screen flex items-center justify-center">
      <ValidatedForm
        className="w-96 space-y-4"
        method="POST"
        validator={validator}
        defaultValues={defaultValues}
      >
        <Input
          name="username"
          label="Username"
          placeholder="Your username..."
        />
        <Input name="email" label="Email" placeholder="Your email..." />
        <Input
          name="password"
          label="Password"
          type="password"
          placeholder="Your password..."
        />
        <div className="flex items-center space-x-4">
          <Button type="submit" label="Register" />
          <Link
            to={route("/auth/sign-in")}
            className="font-medium text-blue-600 dark:text-blue-500 hover:underline"
          >
            Go to Login
          </Link>
        </div>
      </ValidatedForm>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Speaking now of the session start page, similar to the previous page, we use the loader primitive to return the initial state of the form and then, when submitting the data, we verify their integrity and if it is valid, we create a new session using the authenticator.

// @/app/routes/auth.sign-in.tsx
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { withZod } from "@remix-validated-form/with-zod";
import { ValidatedForm, validationError } from "remix-validated-form";
import { Link, useLoaderData } from "@remix-run/react";
import { route } from "routes-gen";

import { Input } from "~/components/Input";
import { Button } from "~/components/Button";
import type { GetUserByEmailAuth } from "~/common/authSchema";
import { authSchemaWithoutUsername } from "~/common/authSchema";
import {
  EMAIL_PASSWORD_STRATEGY,
  authenticator,
} from "~/auth/authenticator.server";

const validator = withZod(authSchemaWithoutUsername);

export const loader: LoaderFunction = () => {
  const defaultValues: GetUserByEmailAuth = {
    email: "",
    password: "",
  };
  return json({ defaultValues });
};

export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();

  const fieldValues = await validator.validate(formData);
  if (fieldValues.error) return validationError(fieldValues.error);

  return await authenticator.authenticate(EMAIL_PASSWORD_STRATEGY, request, {
    successRedirect: route("/protected"),
    context: { formData },
  });
};

export default function SigninPage() {
  const { defaultValues } = useLoaderData<typeof loader>();

  return (
    <div className="w-screen h-screen flex items-center justify-center">
      <ValidatedForm
        className="w-96 space-y-4"
        method="POST"
        validator={validator}
        defaultValues={defaultValues}
      >
        <Input name="email" label="Email" placeholder="Your email..." />
        <Input
          name="password"
          label="Password"
          type="password"
          placeholder="Your password..."
        />
        <div className="flex items-center space-x-4">
          <Button type="submit" label="Login" />
          <Link
            to={route("/auth/sign-up")}
            className="font-medium text-blue-600 dark:text-blue-500 hover:underline"
          >
            Go to Register
          </Link>
        </div>
      </ValidatedForm>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Last but not least we will create the main page of the protected pages layout, in which we will take advantage of the action primitive in which we will use the authenticator to remove the user session. In JSX we will have a simple form with a button to trigger the end of session.

// @/app/routes/protected._index.tsx
import type { ActionFunction } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { route } from "routes-gen";

import { authenticator } from "~/auth/authenticator.server";
import { Button } from "~/components/Button";

export const action: ActionFunction = async ({ request }) => {
  await authenticator.logout(request, { redirectTo: route("/auth/sign-in") });
};

export default function ProtectedMain() {
  return (
    <div className="space-y-4 mt-4">
      <h1>Protected Main Page</h1>
      <small>This (nested) route is protected by the parent.</small>

      <Form method="POST">
        <Button type="submit" label="Logout" />
      </Form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

And with that I conclude the last step of this article.

Conclusion

I hope you found this article helpful, whether you're using the information in an existing project or just giving it a try for fun.

Please let me know if you notice any mistakes in the article by leaving a comment. And, if you'd like to see the source code for this article, you can find it on the github repository linked below.

Github Repo

Top comments (0)