Add Auth to Your Remix App in 5 Minutes

06 Apr 2026 · Bank K.

Set up authentication in your Remix app with sessions, protected routes, and login forms. Step-by-step code with real examples.

You picked Remix for your app. The routes are set up, the database is connected, and your loader functions are returning data. Now someone asks “can I log in?” and suddenly you’re comparing auth libraries, reading about cookie sessions vs JWTs, and wondering if you should just use Passport.js even though it feels wrong in 2026.

Remix has a great server-side model for auth. Loaders and actions run on the server. Cookies are first-class. You don’t need client-side auth state management. But the framework doesn’t ship with auth built in, so you have to pick your own path.

This guide walks through adding authentication to a Remix app — from zero to protected routes — using the least amount of code that actually works.

What You’re Building

By the end of this tutorial, your Remix app will have:

  • A signup page that creates user accounts with hashed passwords
  • A login page that sets a secure session cookie
  • A utility that validates sessions in any loader or action
  • Protected routes that redirect unauthenticated users to login
  • A logout action that destroys the session

Remix gives you createCookieSessionStorage out of the box. No extra dependencies for session management. You just need a password hasher and a database.

Install Dependencies

npm install bcryptjs

That’s it. bcryptjs handles password hashing. Remix handles sessions natively. You’ll also need a database — Prisma, Drizzle, or even SQLite works fine for an MVP.

Create the Session Storage

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

const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: "__session",
    httpOnly: true,
    maxAge: 60 * 60 * 24 * 7, // 7 days
    path: "/",
    sameSite: "lax",
    secrets: [process.env.SESSION_SECRET!],
    secure: process.env.NODE_ENV === "production",
  },
});

export async function createUserSession(userId: string, redirectTo: string) {
  const session = await sessionStorage.getSession();
  session.set("userId", userId);
  return redirect(redirectTo, {
    headers: {
      "Set-Cookie": await sessionStorage.commitSession(session),
    },
  });
}

export async function getUserId(request: Request): Promise<string | null> {
  const session = await sessionStorage.getSession(
    request.headers.get("Cookie")
  );
  return session.get("userId") ?? null;
}

export async function requireUserId(request: Request): Promise<string> {
  const userId = await getUserId(request);
  if (!userId) {
    throw redirect("/login");
  }
  return userId;
}

export async function destroySession(request: Request) {
  const session = await sessionStorage.getSession(
    request.headers.get("Cookie")
  );
  return redirect("/login", {
    headers: {
      "Set-Cookie": await sessionStorage.destroySession(session),
    },
  });
}

This file is the core of your auth system. createUserSession sets a cookie after login. requireUserId protects any route. destroySession handles logout.

Build the Signup Action

// app/routes/signup.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import bcrypt from "bcryptjs";
import { db } from "~/utils/db.server";
import { createUserSession } from "~/utils/session.server";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const email = String(formData.get("email"));
  const password = String(formData.get("password"));

  // Basic validation
  if (!email.includes("@") || password.length < 8) {
    return { error: "Invalid email or password (min 8 chars)" };
  }

  // Check if user exists
  const existingUser = await db.user.findUnique({ where: { email } });
  if (existingUser) {
    return { error: "A user with this email already exists" };
  }

  // Create user
  const hashedPassword = await bcrypt.hash(password, 10);
  const user = await db.user.create({
    data: { email, passwordHash: hashedPassword },
  });

  return createUserSession(user.id, "/dashboard");
}

export default function Signup() {
  const actionData = useActionData<typeof action>();

  return (
    <Form method="post">
      <h1>Sign Up</h1>
      {actionData?.error && <p style={{ color: "red" }}>{actionData.error}</p>}
      <input type="email" name="email" placeholder="Email" required />
      <input type="password" name="password" placeholder="Password" required />
      <button type="submit">Create Account</button>
    </Form>
  );
}

Build the Login Action

// app/routes/login.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import bcrypt from "bcryptjs";
import { db } from "~/utils/db.server";
import { createUserSession } from "~/utils/session.server";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const email = String(formData.get("email"));
  const password = String(formData.get("password"));

  const user = await db.user.findUnique({ where: { email } });
  if (!user) {
    return { error: "Invalid email or password" };
  }

  const valid = await bcrypt.compare(password, user.passwordHash);
  if (!valid) {
    return { error: "Invalid email or password" };
  }

  return createUserSession(user.id, "/dashboard");
}

export default function Login() {
  const actionData = useActionData<typeof action>();

  return (
    <Form method="post">
      <h1>Log In</h1>
      {actionData?.error && <p style={{ color: "red" }}>{actionData.error}</p>}
      <input type="email" name="email" placeholder="Email" required />
      <input type="password" name="password" placeholder="Password" required />
      <button type="submit">Log In</button>
    </Form>
  );
}

Protect a Route

// app/routes/dashboard.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { requireUserId } from "~/utils/session.server";
import { db } from "~/utils/db.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const userId = await requireUserId(request);
  const user = await db.user.findUnique({ where: { id: userId } });
  return { user };
}

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

  return (
    <div>
      <h1>Dashboard</h1>
      <p>Welcome, {user?.email}</p>
    </div>
  );
}

That’s it. Any route that calls requireUserId in its loader will redirect unauthenticated users to /login. No middleware. No context providers. No client-side state. Just a function call at the top of your loader.

Add Logout

// app/routes/logout.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { destroySession } from "~/utils/session.server";

export async function action({ request }: ActionFunctionArgs) {
  return destroySession(request);
}

Then add a logout button anywhere in your app:

<Form method="post" action="/logout">
  <button type="submit">Log Out</button>
</Form>

Option 2: Remix Auth (Strategy-Based)

If you want OAuth providers (Google, GitHub, Discord) without building the flows yourself, remix-auth is the community standard. It’s inspired by Passport.js but built on top of the Web Fetch API, so it fits Remix’s server model properly.

Install It

npm install remix-auth remix-auth-form remix-auth-google

Set Up the Authenticator

// app/utils/auth.server.ts
import { Authenticator } from "remix-auth";
import { FormStrategy } from "remix-auth-form";
import { GoogleStrategy } from "remix-auth-google";
import bcrypt from "bcryptjs";
import { db } from "./db.server";
import { sessionStorage } from "./session.server";

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

// Email/password strategy
authenticator.use(
  new FormStrategy(async ({ form }) => {
    const email = String(form.get("email"));
    const password = String(form.get("password"));

    const user = await db.user.findUnique({ where: { email } });
    if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
      throw new Error("Invalid credentials");
    }
    return user;
  }),
  "form"
);

// Google OAuth strategy
authenticator.use(
  new GoogleStrategy(
    {
      clientID: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      callbackURL: "/auth/google/callback",
    },
    async ({ profile }) => {
      const user = await db.user.upsert({
        where: { email: profile.emails[0].value },
        create: { email: profile.emails[0].value },
        update: {},
      });
      return user;
    }
  ),
  "google"
);

Remix Auth uses a “strategy” pattern. Each authentication method — form-based, Google, GitHub — is a separate strategy you plug in. The authenticator manages them all and stores the result in your session.

Use It in Routes

// app/routes/login.tsx
import { authenticator } from "~/utils/auth.server";

export async function action({ request }: ActionFunctionArgs) {
  return authenticator.authenticate("form", request, {
    successRedirect: "/dashboard",
    failureRedirect: "/login",
  });
}

This is cleaner than writing the bcrypt comparison yourself, and it gives you a path to add OAuth providers later without rewriting your auth layer.

Option 3: Skip the Plumbing Entirely

Both options above work. But they both require you to build and maintain auth infrastructure: password reset flows, email verification, session cleanup, CSRF protection, rate limiting. For a side project or MVP, that’s a lot of surface area that has nothing to do with your actual product.

Beag gives you auth + Stripe payments as a drop-in package. One install, and your Remix app gets login, signup, password reset, and a billing portal. The setup takes about 5 minutes:

npx beag init --framework remix

This scaffolds auth routes, session management, and Stripe webhook handlers. You get a working /login, /signup, /dashboard, and /billing page out of the box. No session management code to write, no payment integration to debug.

If you’ve already built the auth layer using the approach above, Beag can handle just the payments side — check the docs for the Stripe-only setup.

Common Mistakes When Adding Auth to Remix

Forgetting to forward Set-Cookie headers on redirect. This is the most common Remix auth bug. When you call redirect() after login, you must include the Set-Cookie header. Without it, the session cookie never reaches the browser and the user appears logged out on the next page.

Using process.env in client code. Remix has a clear server/client boundary. Your session secret, database URL, and OAuth credentials should only exist in .server.ts files. If you import a server module in a client route, the build will fail or worse — leak secrets.

Not setting httpOnly: true on cookies. Without this flag, any XSS vulnerability on your site lets an attacker read session tokens via JavaScript. Always set httpOnly, secure (in production), and sameSite: "lax" at minimum.

Skipping password validation on signup. At minimum, enforce a character length. Better yet, check against common passwords. Users will try to sign up with “password123” and then blame you when their account gets compromised.

No session expiration. Sessions without a maxAge live forever. Set a reasonable expiration — 7 days is standard for most apps. Clean up expired sessions from your database on a schedule.

Remix Auth Libraries in 2026

The landscape has stabilized since React Router v7 merged with Remix. Here’s what’s available:

LibraryApproachBest For
remix-authStrategy-based (OAuth, form, etc.)Apps needing multiple auth providers
Better AuthFull-featured auth toolkitProduction apps with complex requirements
Custom (this guide)Sessions + cookiesSimple apps, learning, full control
BeagAuth + payments bundleMVPs and indie hacker projects

For most side projects, the custom cookie-based approach in this guide covers 90% of what you need. When you need OAuth providers, swap in remix-auth. When you need auth and payments together, look at an all-in-one solution.

FAQ

Is Remix good for building SaaS apps?

Yes. Remix’s server-first model means your auth logic, database queries, and form handling all run on the server by default. You don’t need to manage auth state on the client or worry about exposing API keys. Combined with nested routes and the loader/action pattern, it’s a strong choice for building full-stack SaaS products as a solo developer.

What’s the difference between Remix and React Router v7?

Since late 2024, Remix and React Router have been merging. React Router v7 includes most of Remix’s features — loaders, actions, server rendering. If you’re starting a new project, React Router v7 with framework mode gives you the same capabilities. The auth patterns in this guide work with both.

Should I use JWTs or cookies for auth in Remix?

Cookies. Remix has built-in support for cookie-based sessions via createCookieSessionStorage. Cookies are sent automatically on every request, work with server-side loaders, and don’t require client-side token management. JWTs make sense for stateless APIs, but for a full-stack Remix app, cookies are simpler and more secure by default.

How do I add Google login to a Remix app?

Use the remix-auth library with the remix-auth-google strategy. Set up a Google OAuth app in the Google Cloud Console, add your client ID and secret as environment variables, configure the strategy as shown in Option 2 above, and create a callback route. The whole process takes about 15 minutes.

Can I add Stripe payments to a Remix app?

Yes. You can integrate Stripe directly using their Node.js SDK in Remix action functions, or use Beag to get auth and Stripe billing pre-configured for Remix. Beag handles webhook verification, subscription management, and the billing portal so you can focus on building your product instead of payment infrastructure.

About the Author
Bank K.

Bank K.

Serial entrepreneur & Co-founder of Beag.io

Founder of Beag.io. Indie hacker building tools to help developers ship faster.

Ready to Make Money From Your SaaS?

Turn your SaaS into cash with Beag.io. Get started now!

Start 7-day free trial →