Back to guides

How to use Own Auth with Remix


Run Own Auth in Remix server modules and store its opaque session token in a Secure HttpOnly cookie. Actions handle authentication mutations and loaders verify sessions for protected requests.

The implementation covers email and password sign-in, protected routes, magic links, and immediate session revocation.

What you will build

  • A server-only Own Auth instance
  • Sign-up and sign-in action functions
  • An opaque session token stored in an HttpOnly cookie
  • Loader-based session validation for each request
  • Protected routes that redirect unauthenticated users
  • Forms with Remix Form and useActionData
  • Magic-link request and verification routes
  • Sign-out with immediate session revocation

How Own Auth fits into Remix

Request flow
Browser form
-> Remix action or loader
-> own-auth
-> Postgres

The browser stores one opaque HttpOnly session token.
Loaders validate it and pass session data to the component.
Actions handle sign-in, sign-up, and sign-out mutations.

Install and migrate

Install Own Auth and a schema validator, then apply the Own Auth migrations to your Postgres database.

Terminal
npm install own-auth zod
npx own-auth migrate
npx own-auth status

Own Auth manages only its own auth tables. If the Remix application also uses Prisma, Drizzle, or another migration tool, keep each tool responsible for its own tables.

Configure the server environment

.env
DATABASE_URL=postgres://user:password@localhost:5432/myapp
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secret
APP_URL=http://localhost:5173

Generate the token pepper once, store it with production secrets, and keep it stable. Changing it invalidates existing sessions, authentication links, SMS codes, and API keys.

Create the Own Auth server module

The .server.ts suffix tells Remix this module runs only on the server. Create one shared Own Auth instance that reads environment values through process.env.

app/lib/auth.server.ts
import { createOwnAuth } from "own-auth";

const appUrl = process.env.APP_URL;
if (!appUrl) throw new Error("APP_URL is required");

if (!process.env.OWN_AUTH_TOKEN_PEPPER) {
  throw new Error("OWN_AUTH_TOKEN_PEPPER is required");
}

export const auth = createOwnAuth({
  tokenPepper: process.env.OWN_AUTH_TOKEN_PEPPER,
  baseUrl: appUrl,
});

Centralize the session cookie

Own Auth returns an opaque session token and stores only its protected hash in Postgres. Put the raw token in a Secure HttpOnly cookie so browser JavaScript cannot read it. Remix's createCookie builds a cookie helper with consistent serialization and parsing.

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",
});

SameSite=Lax is a practical default for a first-party application while still allowing top-level navigation from an authentication email. The Secure flag is enabled only in production so local development works over plain HTTP.

Read and write the session token

Add two helper functions. One parses the session token from an incoming request, and the other creates a Set-Cookie header for an outgoing response.

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

Remix action functions handle form submissions on the server. Validate the form data, call Own Auth, set the session cookie, and redirect. The action returns error data that the component can display with useActionData.

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;
  }

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

Wrong emails and wrong passwords both produce Own Auth's generic invalid-credentials error. Preserve that ambiguity so the form does not reveal which accounts exist.

Build the sign-in form

Remix's Form component submits as a standard POST that works before JavaScript loads. useActionData provides the server response for inline error display.

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

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

  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>}

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

The action and default export live in the same route module. Remix calls the action on POST and renders the default export on GET.

Add sign-up

The sign-up action follows the same pattern. Validate name, email, and password, call signUpEmailPassword, set the session cookie, and redirect. Sign-up may report that an email already exists because the user must resolve that conflict.

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

Create a helper that reads the session cookie, verifies the database-backed session with Own Auth, and returns the result. Clear the cookie if the session is expired or revoked so the browser stops sending it.

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

Use a loader to redirect unauthenticated requests. Return only the user fields the component needs. The session token stays in the HttpOnly cookie and must not appear in loader data.

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);
  }

  return { user: currentAuth.user };
}

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

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

Sign out and revoke the session

Revoke the database session before clearing the browser cookie. Use an action because sign-out changes server state and should not be a GET request.

app/routes/signout.tsx
import { 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);

  try {
    if (token) await auth.signOut(token);
  } finally {
    // Always clear the cookie, even if revocation fails
  }

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

Trigger sign-out from any page with a Form that posts to the signout 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-link sign-in

Configure an Own Auth email provider, then add one action to request the email and one loader to consume the one-time token. Always return the same request confirmation regardless of whether an account exists.

app/routes/signin.magic-link.tsx
import { json } from "@remix-run/node";
import type { ActionFunctionArgs } from "@remix-run/node";
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 },
    );
  }

  await auth.requestMagicLink({ email: parsed.data.email });

  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;
  }

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

Production checklist

  • Use HTTPS and a Node-compatible Remix adapter in production
  • Keep the Own Auth instance and all secrets in .server.ts modules
  • Store the opaque session token only in a Secure HttpOnly cookie
  • Verify the database session in the loader and each protected action
  • Use the verified Own Auth user ID for ownership and authorization checks
  • Use POST actions for sign-in, sign-up, and sign-out
  • Return generic errors for invalid credentials and magic-link requests
  • Never serialize or log passwords, session tokens, provider credentials, or magic-link URLs

FAQ

Can I use Own Auth with Remix SPA mode?

Not directly. Own Auth needs a trusted server with Postgres access and a private token pepper. Remix SPA mode removes the server runtime, so there is nowhere to run Own Auth safely. Use the standard Remix server runtime or a separate backend.

Why createCookie instead of createCookieSessionStorage?

Own Auth manages database-backed sessions and returns an opaque token. The cookie only stores that single token, not a session object. createCookie is the right level of abstraction because there is no session data to serialize into the cookie itself.

Is checking the session in a layout loader enough?

A layout loader can redirect unauthenticated users, but each protected action must still verify the session independently. A redirected page does not authorize a write. Always call getCurrentAuth in actions that mutate data.

Conclusion

The Remix integration keeps responsibilities clear. Own Auth owns authentication records and revocable database sessions. Remix owns forms, actions, loaders, cookies, routing, rendering, and authorization around application data. The .server.ts boundary keeps secrets off the client and avoids duplicating auth logic in the framework layer.