@cfast/auth
Overview
Section titled “Overview”@cfast/auth is a pre-configured Better Auth setup purpose-built for Cloudflare Workers with D1. It handles magic email links and passkeys for passwordless login, session management on D1, and a complete role system that plugs directly into @cfast/permissions.
You declare your roles in permissions, configure auth once, and the two packages stay in sync. Changing a user’s role immediately changes what database operations they can run via @cfast/db.
Installation
Section titled “Installation”pnpm add @cfast/authPeer dependencies: @cfast/permissions, drizzle-orm, better-auth, react, react-router.
Quick Setup
Section titled “Quick Setup”Server Configuration
Section titled “Server Configuration”import { createAuth } from "@cfast/auth";import { permissions } from "./permissions";
export const initAuth = createAuth({ permissions, magicLink: { sendMagicLink: async ({ email, url }) => { // Send email with your provider (Mailgun, Resend, etc.) }, }, passkeys: { rpName: "MyApp", rpId: "myapp.com", }, session: { expiresIn: "30d" }, redirects: { afterLogin: "/", loginPath: "/login", },});
// In your request handler, initialize with D1:const auth = initAuth({ d1: env.DB, appUrl: "https://myapp.com" });Route Registration
Section titled “Route Registration”Add auth routes (magic link callback, passkey endpoints) in your routes.ts:
import type { RouteConfig } from "@react-router/dev/routes";import { authRoutes } from "@cfast/auth/plugin";
export default [ ...authRoutes({ handlerFile: "routes/auth.$.tsx" }), // ... other routes] satisfies RouteConfig;The handler file forwards requests to Better Auth:
// routes/auth.$.tsximport { createAuthRouteHandlers } from "@cfast/auth";const { loader, action } = createAuthRouteHandlers(() => getAuth());export { loader, action };Client Setup
Section titled “Client Setup”Wrap the app root with the auth client provider:
import { AuthClientProvider } from "@cfast/auth/client";import { authClient } from "~/auth.client";
export default function App() { return ( <AuthClientProvider authClient={authClient}> <Outlet /> </AuthClientProvider> );}Core Concepts
Section titled “Core Concepts”Passwordless Authentication
Section titled “Passwordless Authentication”Auth supports two passwordless methods, both offered on the login page:
- Magic email links — The user enters their email and receives a link. Clicking it verifies the token, creates or updates the user, and establishes a session. The callback route is injected automatically by
authRoutes(). - Passkeys (WebAuthn) — The user authenticates with a hardware key or platform biometric. The WebAuthn ceremony is managed by the
LoginPagecomponent. Users register passkeys from a settings page after their first login.
Cookie-Based Redirect Flow
Section titled “Cookie-Based Redirect Flow”When an unauthenticated user visits a protected route, the intended path is stored in a cfast_redirect_to cookie (HttpOnly, Secure, SameSite=Lax, 10-minute TTL). After login, the user is redirected back to where they were going. If no cookie exists (direct visit to /login), the afterLogin default from config is used.
Roles Bridge Auth and Permissions
Section titled “Roles Bridge Auth and Permissions”Roles assigned via @cfast/auth directly determine what @cfast/permissions grants apply. When you call createDb({ user }), the user’s roles determine which Operations succeed and which rows are visible:
export async function loader({ request, context }) { const user = await auth.requireUser(request);
const db = createDb({ d1: context.env.DB, schema, permissions, user, // roles from auth determine permission grants });
const posts = await db.query(postsTable).findMany().run({}); return { user, posts };}Common Patterns
Section titled “Common Patterns”Protecting Routes with AuthGuard
Section titled “Protecting Routes with AuthGuard”AuthGuard is a layout-level component that provides the user to all child routes via context:
import { AuthGuard } from "@cfast/auth/client";
export async function loader({ request }) { const ctx = await requireAuthContext(request); return { user: ctx.user };}
export default function ProtectedLayout() { const { user } = useLoaderData<typeof loader>(); return ( <AuthGuard user={user}> <Outlet /> </AuthGuard> );}Any route nested under _protected is automatically guarded. Access the user with useCurrentUser():
import { useCurrentUser } from "@cfast/auth/client";
function Header() { const user = useCurrentUser(); return <span>{user?.email}</span>;}Login Page with Component Slots
Section titled “Login Page with Component Slots”Render <LoginPage> in your login route. Customize the UI by passing component slot overrides or use the pre-built Joy UI components from @cfast/ui:
import { LoginPage } from "@cfast/auth/client";import { joyLoginComponents } from "@cfast/joy";import { authClient } from "~/auth.client";
export default function Login() { return ( <LoginPage authClient={authClient} components={joyLoginComponents} title="Sign In" subtitle="Sign in to My App" /> );}Individual slots (Layout, EmailInput, PasskeyButton, MagicLinkButton, SuccessMessage, ErrorMessage) can be overridden independently.
Role Management and Impersonation
Section titled “Role Management and Impersonation”Control who can assign which roles, and let admins see exactly what a user sees:
// Role grant rules -- restrict who can assign whatconst auth = createAuth({ permissions, roleGrants: { admin: ["admin", "editor", "user"], editor: ["user"], },});
// Assign rolesawait auth.setRole(userId, "editor");await auth.setRoles(userId, ["editor", "moderator"]);
// Impersonation for debugging and supportawait auth.impersonate(adminUserId, targetUserId);
// Client-side: detect impersonationconst user = useCurrentUser();// user.isImpersonating -- true when admin is impersonating// user.realUser -- { id, name } of the admin
const { stopImpersonating } = useAuth();await stopImpersonating();Test Helpers for Passkeys
Section titled “Test Helpers for Passkeys”@cfast/auth exports two helpers for testing passkey flows end-to-end. The workflow has two steps: seed a credential in your database, then register it as a virtual authenticator in Playwright.
Step 1 — Seed a passkey credential:
import { generateTestPasskey } from "@cfast/auth/test";
const fixture = generateTestPasskey({ userId: "test-user-id", rpId: "localhost",});// fixture contains { credentialId, publicKey, ... } — insert into your DB seed scriptStep 2 — Register the virtual authenticator in Playwright:
import { addPasskeyCredential } from "@cfast/auth/test";
test("login with passkey", async ({ page }) => { await addPasskeyCredential(page, fixture); // The browser now has a virtual authenticator matching the seeded credential await page.goto("/login"); await page.getByRole("button", { name: "Sign in with passkey" }).click(); await expect(page).toHaveURL("/");});generateTestPasskey creates a deterministic credential from the user ID and RP ID. addPasskeyCredential uses the CDP WebAuthn domain to register it as a virtual authenticator on the page, so the WebAuthn ceremony completes without real hardware.
Auth Helpers with createAuthHelpers()
Section titled “Auth Helpers with createAuthHelpers()”Every cfast app copy-pastes the same ~35 lines of auth.helpers.server.ts boilerplate: get the auth instance, call createContext, extract the user, redirect if missing. createAuthHelpers() replaces all of it with a single factory:
import { createAuthHelpers } from "@cfast/auth/helpers";import { initAuth } from "./auth.setup.server";import { env } from "./env";
const { getAuthContext, requireAuthContext, getUser, requireUser } = createAuthHelpers({ getAuth: () => initAuth({ d1: env.get().DB, appUrl: env.get().APP_URL }), });
export { getAuthContext, requireAuthContext, getUser, requireUser };The returned helpers are:
getAuthContext(request)— Returns{ user, grants }whereuserisnullif unauthenticated.requireAuthContext(request)— Same, but throws a 302 redirect to the login page if the user is not authenticated.getUser(request)— Returns the user ornull.requireUser(request)— Returns the user or redirects.
Extending the user with enrichUser
Section titled “Extending the user with enrichUser”Apps that need custom fields on the user object (e.g., vendorId for a store app) pass an enrichUser hook. The enriched type flows through to all return types:
const { requireUser } = createAuthHelpers({ getAuth: () => initAuth({ d1: env.get().DB, appUrl: env.get().APP_URL }), enrichUser: async (user) => { const vendor = await lookupVendor(user.id); return { ...user, vendorId: vendor?.id ?? null }; },});
// requireUser now returns { id, email, name, vendorId: string | null, ... }