Authentication
Catmint does not include a built-in authentication system. Instead, it provides the primitives -- cookies, middleware, server functions, and AsyncLocalStorage context -- to implement authentication patterns that fit your requirements. This guide covers common patterns for session-based auth, route protection, and role-based access control.
Session-Based Authentication
The most common pattern uses HTTP-only cookies to store a session identifier. The session is validated on each request via middleware.
Login Server Function
// app/auth/login.fn.ts
import { createServerFn } from "catmint/server";
import { cookies } from "catmint/cookies";
export const login = createServerFn(
async (input: { email: string; password: string }) => {
const user = await db.users.findByEmail(input.email);
if (!user || !(await verifyPassword(input.password, user.passwordHash))) {
throw new Error("Invalid email or password");
}
const session = await db.sessions.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
cookies().set("session", session.id, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 7 * 24 * 60 * 60,
});
return { user: { id: user.id, email: user.email, role: user.role } };
},
);
Logout Server Function
// app/auth/logout.fn.ts
import { createServerFn } from "catmint/server";
import { cookies } from "catmint/cookies";
export const logout = createServerFn(async () => {
const sessionId = cookies().get("session")?.value;
if (sessionId) {
await db.sessions.delete({ where: { id: sessionId } });
}
cookies().delete("session", {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
});
return { success: true };
});
Login Page
// app/login/page.client.tsx
import { useState } from "react";
import { login } from "../auth/login.fn";
export default function LoginPage() {
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
const formData = new FormData(e.currentTarget);
try {
await login({
email: formData.get("email") as string,
password: formData.get("password") as string,
});
window.location.href = "/dashboard";
} catch (err) {
setError((err as Error).message);
}
}
return (
<form onSubmit={handleSubmit}>
{error && <p>{error}</p>}
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">Sign in</button>
</form>
);
}
Protecting Routes with Middleware
Use middleware to validate the session cookie and protect routes. The middleware runs before the page renders and can redirect unauthenticated users.
// app/(protected)/middleware.ts
import { defineMiddleware } from "catmint/middleware";
import { cookies } from "catmint/cookies";
export const middleware = defineMiddleware(async (ctx) => {
const sessionId = cookies().get("session")?.value;
if (!sessionId) {
return new Response(null, {
status: 302,
headers: { Location: "/login" },
});
}
const session = await db.sessions.findUnique({
where: { id: sessionId },
include: { user: true },
});
if (!session || session.expiresAt < new Date()) {
cookies().delete("session");
return new Response(null, {
status: 302,
headers: { Location: "/login" },
});
}
// Store the user in context for downstream use
ctx.set("user", session.user);
return ctx.next();
});
Place this middleware inside a route group to protect all routes within it:
app/
(public)/
login/page.tsx # Not protected
signup/page.tsx # Not protected
(protected)/
middleware.ts # Auth middleware
dashboard/page.tsx # Protected
settings/page.tsx # Protected
profile/page.tsx # Protected
Auth Context with AsyncLocalStorage
Catmint uses AsyncLocalStorage to
propagate request context through the middleware chain, server functions, and
layout rendering. Once a user is stored in context by middleware, it can be
accessed anywhere on the server side during that request.
// app/auth/context.ts
import { createContext } from "catmint/context";
type User = {
id: string;
email: string;
role: "admin" | "user";
};
export const authContext = createContext<User | null>("auth", null);
// app/(protected)/middleware.ts
import { defineMiddleware } from "catmint/middleware";
import { cookies } from "catmint/cookies";
import { authContext } from "../auth/context";
export const middleware = defineMiddleware(async (ctx) => {
const sessionId = cookies().get("session")?.value;
if (!sessionId) {
return new Response(null, {
status: 302,
headers: { Location: "/login" },
});
}
const session = await db.sessions.findUnique({
where: { id: sessionId },
include: { user: true },
});
if (!session || session.expiresAt < new Date()) {
return new Response(null, {
status: 302,
headers: { Location: "/login" },
});
}
authContext.set(session.user);
return ctx.next();
});
// app/dashboard/data.fn.ts
import { createServerFn } from "catmint/server";
import { authContext } from "../auth/context";
export const getDashboardData = createServerFn(async () => {
const user = authContext.get();
if (!user) {
throw new Error("Unauthorized");
}
return db.dashboardData.findMany({
where: { userId: user.id },
});
});
Role-Based Access Control
Extend the auth middleware to enforce role-based access. You can create separate middleware for different access levels:
// app/(admin)/middleware.ts
import { defineMiddleware } from "catmint/middleware";
import { authContext } from "../auth/context";
export const middleware = defineMiddleware(async (ctx) => {
const user = authContext.get();
if (!user) {
return new Response(null, {
status: 302,
headers: { Location: "/login" },
});
}
if (user.role !== "admin") {
return new Response("Forbidden", { status: 403 });
}
return ctx.next();
});
The directory layout controls which middleware applies:
app/
middleware.ts # Root: session validation
(protected)/
middleware.ts # Requires authenticated user
dashboard/page.tsx # Any authenticated user
profile/page.tsx # Any authenticated user
(admin)/
middleware.ts # Requires admin role
admin/page.tsx # Admin only
admin/users/page.tsx # Admin only
JWT Patterns
For stateless authentication, you can use JWTs instead of database-backed sessions. The token is stored in a cookie and validated in middleware without a database lookup.
// app/auth/jwt.server.ts
import { SignJWT, jwtVerify } from "jose";
import { env } from "catmint/env";
const secret = new TextEncoder().encode(env.private.JWT_SECRET);
export async function signToken(payload: { userId: string; role: string }) {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(secret);
}
export async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret);
return payload as { userId: string; role: string };
} catch {
return null;
}
}
// app/auth/login.fn.ts
import { createServerFn } from "catmint/server";
import { cookies } from "catmint/cookies";
import { signToken } from "./jwt.server";
export const login = createServerFn(
async (input: { email: string; password: string }) => {
const user = await db.users.findByEmail(input.email);
if (!user || !(await verifyPassword(input.password, user.passwordHash))) {
throw new Error("Invalid email or password");
}
const token = await signToken({ userId: user.id, role: user.role });
cookies().set("token", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 7 * 24 * 60 * 60,
});
return { user: { id: user.id, email: user.email } };
},
);
// app/(protected)/middleware.ts
import { defineMiddleware } from "catmint/middleware";
import { cookies } from "catmint/cookies";
import { verifyToken } from "../auth/jwt.server";
import { authContext } from "../auth/context";
export const middleware = defineMiddleware(async (ctx) => {
const token = cookies().get("token")?.value;
if (!token) {
return new Response(null, {
status: 302,
headers: { Location: "/login" },
});
}
const payload = await verifyToken(token);
if (!payload) {
cookies().delete("token");
return new Response(null, {
status: 302,
headers: { Location: "/login" },
});
}
authContext.set({ id: payload.userId, role: payload.role });
return ctx.next();
});
Store the JWT secret in
env.privateto prevent it from leaking to the client bundle. Use the.server.tssuffix for modules that handle token signing and verification.
Protecting Server Functions
Server functions run on the server but are callable from client code. Always verify authentication inside server functions that access private data:
// app/settings/actions.fn.ts
import { createServerFn } from "catmint/server";
import { authContext } from "../auth/context";
export const updateProfile = createServerFn(
async (input: { name: string; bio: string }) => {
const user = authContext.get();
if (!user) {
throw new Error("Unauthorized");
}
return db.users.update({
where: { id: user.id },
data: { name: input.name, bio: input.bio },
});
},
);
For a reusable pattern, create a helper that wraps server functions with an auth check:
// app/auth/require.server.ts
import { authContext } from "./context";
export function requireAuth() {
const user = authContext.get();
if (!user) {
throw new Error("Unauthorized");
}
return user;
}
export function requireRole(role: string) {
const user = requireAuth();
if (user.role !== role) {
throw new Error("Forbidden");
}
return user;
}