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
npm install own-auth zodApply the database schema
DATABASE_URL=postgres://user:password@localhost:5432/myappnpx own-auth migrateCreate 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.
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secretimport { 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
APP_URL=http://localhost:5173const 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.
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.",
});
}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,
),
},
});
}