Skip to content

@cfast/auth

@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.

Terminal window
pnpm add @cfast/auth

Peer dependencies: @cfast/permissions, drizzle-orm, better-auth, react, react-router.

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

Add auth routes (magic link callback, passkey endpoints) in your routes.ts:

app/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.$.tsx
import { createAuthRouteHandlers } from "@cfast/auth";
const { loader, action } = createAuthRouteHandlers(() => getAuth());
export { loader, action };

Wrap the app root with the auth client provider:

root.tsx
import { AuthClientProvider } from "@cfast/auth/client";
import { authClient } from "~/auth.client";
export default function App() {
return (
<AuthClientProvider authClient={authClient}>
<Outlet />
</AuthClientProvider>
);
}

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 LoginPage component. Users register passkeys from a settings page after their first login.

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

AuthGuard is a layout-level component that provides the user to all child routes via context:

routes/_protected.tsx
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>;
}

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:

routes/login.tsx
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.

Control who can assign which roles, and let admins see exactly what a user sees:

// Role grant rules -- restrict who can assign what
const auth = createAuth({
permissions,
roleGrants: {
admin: ["admin", "editor", "user"],
editor: ["user"],
},
});
// Assign roles
await auth.setRole(userId, "editor");
await auth.setRoles(userId, ["editor", "moderator"]);
// Impersonation for debugging and support
await auth.impersonate(adminUserId, targetUserId);
// Client-side: detect impersonation
const user = useCurrentUser();
// user.isImpersonating -- true when admin is impersonating
// user.realUser -- { id, name } of the admin
const { stopImpersonating } = useAuth();
await stopImpersonating();

@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 script

Step 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.

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:

app/auth.helpers.server.ts
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 } where user is null if unauthenticated.
  • requireAuthContext(request) — Same, but throws a 302 redirect to the login page if the user is not authenticated.
  • getUser(request) — Returns the user or null.
  • requireUser(request) — Returns the user or redirects.

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