Back to guides

Own Auth with Remix


Call Own Auth from Remix actions and keep the opaque session token in an HttpOnly cookie. Loaders verify the session before returning protected route data.

Install Own Auth

Terminal
npm install own-auth zod

Apply the database schema

.env
DATABASE_URL=postgres://user:password@localhost:5432/myapp
Terminal
npx own-auth migrate

Create the auth instance

Add a stable token pepper, then create the Own Auth instance in a .server.ts module that Remix excludes from the browser bundle.

.env
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secret
app/lib/auth.server.ts
import { createOwnAuth } from "own-auth";

const tokenPepper = process.env.OWN_AUTH_TOKEN_PEPPER!;

export const auth = createOwnAuth({
  tokenPepper,
});

Create the session cookie

Use createCookie for the opaque session token.

app/lib/session-cookie.server.ts
import { createCookie } from "@remix-run/node";

export const sessionCookie = createCookie("own_auth_session", {
  httpOnly: true,
  path: "/",
  sameSite: "lax",
  secure: process.env.NODE_ENV === "production",
});

Read and write the session token

Read, set, and clear the cookie through one server module.

app/lib/session-helpers.server.ts
import { sessionCookie } from "./session-cookie.server";

export async function getSessionToken(request: Request) {
  const header = request.headers.get("Cookie");
  return (await sessionCookie.parse(header)) as string | null;
}

export async function setSessionCookie(
  token: string,
  expires: Date,
) {
  return sessionCookie.serialize(token, { expires });
}

export async function clearSessionCookie() {
  return sessionCookie.serialize("", { maxAge: 0 });
}

Create the sign-in action

Validate the form data and call auth.signInEmailPassword. A complete result redirects with the session cookie. An MFA result returns its challenge token, methods, and expiry to the form.

app/routes/signin.tsx
import { redirect, json } from "@remix-run/node";
import type { ActionFunctionArgs } from "@remix-run/node";
import { AuthError } from "own-auth";
import { z } from "zod";
import { auth } from "~/lib/auth.server";
import { setSessionCookie } from "~/lib/session-helpers.server";

const schema = z.object({
  email: z.string().trim().email(),
  password: z.string().min(1),
});

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const parsed = schema.safeParse(Object.fromEntries(formData));

  if (!parsed.success) {
    return json(
      { error: "Enter a valid email and password." },
      { status: 400 },
    );
  }

  let result;
  try {
    result = await auth.signInEmailPassword(parsed.data);
  } catch (error) {
    if (error instanceof AuthError) {
      return json(
        { error: error.safeMessage },
        { status: error.statusCode },
      );
    }
    throw error;
  }

  if (result.status === "mfa_required") {
    return json({
      status: result.status,
      challengeToken: result.challengeToken,
      methods: result.methods,
      expiresAt: result.expiresAt,
    });
  }

  return redirect("/dashboard", {
    headers: {
      "Set-Cookie": await setSessionCookie(
        result.sessionToken,
        result.session.expiresAt,
      ),
    },
  });
}

Build the sign-in form

Form posts to the route action. useActionData exposes validation errors and the MFA result to the same page.

app/routes/signin.tsx
import { Form, useActionData } from "@remix-run/react";

type ActionData = {
  error?: string;
  status?: "mfa_required";
  challengeToken?: string;
  methods?: string[];
  expiresAt?: string;
};

export default function SignInPage() {
  const data = useActionData<ActionData>();

  return (
    <Form method="post">
      <label>
        Email
        <input
          name="email"
          type="email"
          autoComplete="email"
          required
        />
      </label>

      <label>
        Password
        <input
          name="password"
          type="password"
          autoComplete="current-password"
          required
        />
      </label>

      {data?.error && <p role="alert">{data.error}</p>}
      {data?.status === "mfa_required" && (
        <p>Continue with an additional verification method.</p>
      )}

      <button type="submit">Sign in</button>
    </Form>
  );
}

Add sign-up

The sign-up action validates the request shape, calls auth.signUpEmailPassword, and redirects with the returned session cookie.

app/routes/signup.tsx
import { redirect, json } from "@remix-run/node";
import type { ActionFunctionArgs } from "@remix-run/node";
import { AuthError } from "own-auth";
import { z } from "zod";
import { auth } from "~/lib/auth.server";
import { setSessionCookie } from "~/lib/session-helpers.server";

const schema = z.object({
  name: z.string().trim().min(1),
  email: z.string().trim().email(),
  password: z.string().min(1),
});

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const parsed = schema.safeParse(Object.fromEntries(formData));

  if (!parsed.success) {
    return json(
      { error: "Enter a valid name, email, and password." },
      { status: 400 },
    );
  }

  let result;
  try {
    result = await auth.signUpEmailPassword(parsed.data);
  } catch (error) {
    if (error instanceof AuthError) {
      return json(
        { error: error.safeMessage },
        { status: error.statusCode },
      );
    }
    throw error;
  }

  return redirect("/dashboard", {
    headers: {
      "Set-Cookie": await setSessionCookie(
        result.sessionToken,
        result.session.expiresAt,
      ),
    },
  });
}

Validate the session in a loader

Read the cookie and call auth.getCurrentSession from a shared loader helper. Clear the cookie after an expired or revoked session.

app/lib/current-auth.server.ts
import { auth } from "~/lib/auth.server";
import {
  getSessionToken,
  clearSessionCookie,
} from "~/lib/session-helpers.server";

export async function getCurrentAuth(request: Request) {
  const token = await getSessionToken(request);
  if (!token) return { currentAuth: null, headers: null };

  const currentAuth = await auth.getCurrentSession(token);

  if (!currentAuth) {
    return {
      currentAuth: null,
      headers: {
        "Set-Cookie": await clearSessionCookie(),
      },
    };
  }

  return { currentAuth, headers: null };
}

Protect routes with loaders

Redirect unauthenticated requests from the loader. Return only id, email, name, and imageUrl to the component.

app/routes/dashboard.tsx
import { redirect } from "@remix-run/node";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { getCurrentAuth } from "~/lib/current-auth.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const { currentAuth, headers } = await getCurrentAuth(request);

  if (!currentAuth) {
    throw redirect("/signin", headers ? { headers } : undefined);
  }

  const { id, email, name, imageUrl } = currentAuth.user;
  return { user: { id, email, name, imageUrl } };
}

export default function DashboardPage() {
  const { user } = useLoaderData<typeof loader>();

  return <h1>Welcome, {user.name}</h1>;
}

Sign out

Call auth.signOut from an action, then clear the browser cookie.

app/routes/signout.tsx
import { json, redirect } from "@remix-run/node";
import type { ActionFunctionArgs } from "@remix-run/node";
import { auth } from "~/lib/auth.server";
import {
  getSessionToken,
  clearSessionCookie,
} from "~/lib/session-helpers.server";

export async function action({ request }: ActionFunctionArgs) {
  const token = await getSessionToken(request);

  if (token) await auth.signOut(token);

  return redirect("/signin", {
    headers: { "Set-Cookie": await clearSessionCookie() },
  });
}

Post to the sign-out action from any route.

app/components/sign-out-button.tsx
import { Form } from "@remix-run/react";

export function SignOutButton() {
  return (
    <Form method="post" action="/signout">
      <button type="submit">Sign out</button>
    </Form>
  );
}

Add magic links

Magic-link generation needs the application origin. Add APP_URL, then replace the auth initialization in app/lib/auth.server.ts with the version below.

.env
APP_URL=http://localhost:5173
app/lib/auth.server.ts
const appUrl = process.env.APP_URL!;

export const auth = createOwnAuth({
  tokenPepper,
  baseUrl: appUrl,
});

After configuring an email provider, add one action to request a magic link and one loader to verify its token. The request action returns the same confirmation for every email. Complete verification sets the session cookie; MFA returns its challenge payload.

app/routes/signin.magic-link.tsx
import { json } from "@remix-run/node";
import type { ActionFunctionArgs } from "@remix-run/node";
import { AuthError } from "own-auth";
import { z } from "zod";
import { auth } from "~/lib/auth.server";

const schema = z.object({
  email: z.string().trim().email(),
});

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const parsed = schema.safeParse(Object.fromEntries(formData));

  if (!parsed.success) {
    return json(
      { error: "Enter a valid email address." },
      { status: 400 },
    );
  }

  try {
    await auth.requestMagicLink({ email: parsed.data.email });
  } catch (error) {
    if (error instanceof AuthError) {
      return json(
        { error: error.safeMessage },
        { status: error.statusCode },
      );
    }
    throw error;
  }

  return json({
    message: "If that address can sign in, a link is on its way.",
  });
}
app/routes/auth.magic-link.verify.tsx
import { redirect } from "@remix-run/node";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { AuthError } from "own-auth";
import { auth } from "~/lib/auth.server";
import { setSessionCookie } from "~/lib/session-helpers.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const token = url.searchParams.get("token");

  if (!token) {
    return redirect("/signin?error=invalid_link");
  }

  let result;
  try {
    result = await auth.verifyMagicLink({ token });
  } catch (error) {
    if (error instanceof AuthError) {
      return redirect("/signin?error=invalid_link");
    }
    throw error;
  }

  if (result.status === "mfa_required") {
    return json({
      status: result.status,
      challengeToken: result.challengeToken,
      methods: result.methods,
      expiresAt: result.expiresAt,
    });
  }

  return redirect("/dashboard", {
    headers: {
      "Set-Cookie": await setSessionCookie(
        result.sessionToken,
        result.session.expiresAt,
      ),
    },
  });
}