Add Auth to Your Remix App in 5 Minutes
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
Option 1: Cookie-Based Auth from Scratch
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:
| Library | Approach | Best For |
|---|---|---|
| remix-auth | Strategy-based (OAuth, form, etc.) | Apps needing multiple auth providers |
| Better Auth | Full-featured auth toolkit | Production apps with complex requirements |
| Custom (this guide) | Sessions + cookies | Simple apps, learning, full control |
| Beag | Auth + payments bundle | MVPs 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.
Ready to Make Money From Your SaaS?
Turn your SaaS into cash with Beag.io. Get started now!
Start 7-day free trial →