auth
import { getLouiseAuth, resolveEditorSession, handleAuthRequest, requireEditor, defaultResolveAdmins,} from "louise-toolkit/auth";The shared Better Auth setup for a Louise site: magic-link + passkey editor sign-in (allowlist-gated), optional customer email/password, and captcha, behind one request-scoped factory. Framework-agnostic—you wire the helpers into your Astro middleware and routes.
Peer dependencies: better-auth, @better-auth/passkey. Builds on
security (getSessionSecret, LouiseEnv).
getLouiseAuth(env, baseURL, config)
Section titled “getLouiseAuth(env, baseURL, config)”function getLouiseAuth( env: LouiseAuthEnv, baseURL: string, config: LouiseAuthConfig,): Promise<LouiseAuth>;Constructs the request-scoped auth instance. baseURL is the origin (Better
Auth signs callback URLs and binds the passkey rpID against it)—derive it
from the request, so a multi-tenant deployment gets the correct origin-bound
relying party per tenant. Better Auth 1.5+ speaks D1 natively; the binding is
passed straight to database (no adapter).
LouiseAuthConfig
Section titled “LouiseAuthConfig”| field | purpose |
|---|---|
rpName |
passkey relying-party display name |
rpID? |
passkey relying-party domain. Defaults to the request origin’s hostname; pin it to the apex so one passkey covers an admin subdomain too—see below |
mailFrom |
from for the magic-link email |
renderMagicLinkEmail |
render the email body (site branding) |
resolveAdmins? |
Site Admin allowlist; defaults to OWNER_EMAIL + ENGINEER_EMAIL from env. A platform passes a per-tenant tenant_admins lookup |
customers? |
enable customer email/password (omit for an admin-only editor) |
additionalFields? |
extra Better Auth user columns (for example, squareCustomerId) |
tablePrefix? |
namespace the auth tables in the same D1 (for example, "auth_"); must match the value passed to the schema generator. Omit for default table names |
session? |
lifetime overrides (default 45-day rolling, daily refresh) |
sessionCacheKv? |
cache sessions in KV (secondaryStorage + storeSessionInDatabase); omit for D1-only |
verificationStorage? |
where single-use values (magic links, resets) are consumed from; defaults to "database" |
rateLimitDo? |
Durable Object namespace backing Better Auth’s rate limiter |
extraPlugins? |
additional Better Auth plugins |
import { getLouiseAuth } from "louise-toolkit/auth";import { magicLinkEmail } from "./emails";
export const getAuth = (env: Env, baseURL: string) => getLouiseAuth(env, baseURL, { rpName: "My Studio", mailFrom: { email: env.MAIL_FROM, name: "My Studio" }, renderMagicLinkEmail: magicLinkEmail, });Magic-link + admin + passkey are always on; captcha (Turnstile) mounts only
when both a real secret and a real site key are configured.
One passkey across an admin subdomain
Section titled “One passkey across an admin subdomain”rpID is derived from the request origin, which is right for a single-origin
site and wrong the moment an admin app lives on its own subdomain: example.com
and studio.example.com mint two separate passkeys for the same person, who
now enrols twice and picks the right one from a list.
Pin it to the apex and one credential authenticates on both, since a passkey registered for a domain is usable on its subdomains:
getLouiseAuth(env, baseURL, { rpName: "My Studio", rpID: "example.com", // both origins, one credential cookiePrefix: "louise-studio", // …but its own session // …});The sessions stay separate, and that is the point—a shared credential is not a shared login. The combination that makes this safe:
- Host-only cookies. No
Domainattribute andcrossSubDomainCookiesoff, which is the default. Widening the cookie to the parent domain would broadcast the admin session to every sibling subdomain—including untrusted tenant storefronts—which is the failure this option exists to avoid, not cause. - A distinct
cookiePrefixper instance, for the same reason two instances on one origin need one: otherwise the sessions collide.
rpID must be the origin’s own domain or a parent of it—a browser rejects a
registration whose rpID is neither, so a typo fails at enrolment rather than
silently. It is a bare domain: no scheme, no port.
Single-use values stay on D1
Section titled “Single-use values stay on D1”verificationStorage decides where a magic link or password-reset token is
stored and consumed. It only matters alongside sessionCacheKv; without a
secondary storage these always live in D1 anyway.
It defaults to "database", and that default is a security one. Better Auth
requires the storage’s getAndDelete to be atomic, so one of these values
cannot be consumed twice. Cloudflare KV has no atomic primitive, and its
cross-colo convergence widens the window further—two requests racing the same
magic link could both succeed. D1 is strongly consistent and deletes atomically,
so consuming from there closes it, and KV stays what it is good at here: a global
session read cache.
Pass "secondary" to restore the older behaviour. Take it only if you have
measured the extra D1 read on the verification path and decided it matters.
Rate limiting on a Durable Object
Section titled “Rate limiting on a Durable Object”Better Auth checks rateLimit.customStorage before secondary storage, so
setting rateLimitDo means its rate limiting stops going through KV entirely.
getLouiseAuth(env, baseURL, { // … sessionCacheKv: env.SESSIONS, rateLimitDo: env.RATE_LIMIT_DO,});A Durable Object is the only atomic counter on Workers. The KV counter has a read→write gap that undercounts under a burst, and Cloudflare’s native Rate Limiting binding is permissive, eventually consistent and scoped per location—so an attacker spread across colos gets one budget per colo. Fine for form spam, weak for sign-in.
Your site owns the DurableObject subclass and the wrangler binding; see
createRateLimiter for the shape.
resolveEditorSession(auth, request, editorRole?)
Section titled “resolveEditorSession(auth, request, editorRole?)”function resolveEditorSession( auth: LouiseAuth, request: Request, editorRole?: string, // default "admin"): Promise<EditorSession | null>;Re-derives the editor session from the signed Better Auth session on every
request—edit access is never trusted from the client. Returns the editor when
the user holds the editor role, else null. Assign the result to locals in your
Astro middleware.
handleAuthRequest(auth, request, admins)
Section titled “handleAuthRequest(auth, request, admins)”The Better Auth catch-all with the editor magic-link allowlist gate. A non-admin
magic-link request is rejected before Better Auth runs—no token, no mail,
no user row—and returns the same enumeration-safe response a real send does.
Use it in your /api/auth/[...all] route. admins is the resolved allowlist
(the same source resolveAdmins uses).
requireEditor(ctx, mutation?) · isSameOrigin(request)
Section titled “requireEditor(ctx, mutation?) · isSameOrigin(request)”function requireEditor( ctx: { request: Request; editor: EditorSession | null }, mutation?: boolean, // default true): Response | null;Guard for editor-gated endpoints: a same-origin (CSRF) check on mutations plus a
resolved editor session. Returns an error Response, or null to proceed.
Allowlist & Turnstile helpers
Section titled “Allowlist & Turnstile helpers”defaultResolveAdmins(env)—OWNER_EMAIL+ optionalENGINEER_EMAIL, lowercased.isAllowedSignInEmail(admins, email)—case-insensitive membership test.turnstileSiteKey(env),turnstileSecret(env),activeCaptchaSecret(env, secret)—the both-halves-real captcha activation gate.
Generating the auth schema
Section titled “Generating the auth schema”Better Auth doesn’t ship hand-written table DDL—it derives its tables (user,
session, account, verification, passkey, the admin role/ban columns, plus any
additionalFields) from the config. So Louise always generates the auth
migration rather than hand-rolling it, from the same plugin set the runtime
factory uses—the committed schema can’t drift from what getLouiseAuth
expects. One command:
# print to stdout, or write with --outpnpm exec louise gen-auth-schema --out drizzle/0002_auth.sqlgen-auth-schema takes an optional --config <path> (a module default-exporting
an AuthSchemaConfig—{ customers?, additionalFields?, tablePrefix? }) so the
generated columns match your runtime LouiseAuthConfig. Point it at the site’s
auth config (or a small module re-exporting its additionalFields/customers)
and the base tables come from Louise, the extra columns from your config:
louise gen-auth-schema --config ./src/lib/auth-schema.config.ts --out drizzle/0002_auth.sqlThen apply it like any Drizzle/D1 migration (wrangler d1 migrations apply).
Re-run the command whenever the auth config changes—never hand-edit the output.
The programmatic generator is also exported as
generateAuthSchemaSql(config): string.
Where the auth tables live
Section titled “Where the auth tables live”Two supported layouts, chosen per deployment. Both keep one database and one migration stream—the difference is only a table-name namespace:
| Option | Isolation | user↔content joins | Best for |
|---|---|---|---|
| A. Same D1, default names (default) | low | native SQL joins | sites that join user↔content (customer↔order, squareCustomerId)—one owner, one stream |
B. Same D1, auth_ prefix |
medium | still native joins | a visible auth boundary in one database, without a second DB |
Default to A. The sites have real user↔content joins, one owner, and one migration history; a second boundary adds friction for little gain. Choose B only when you want an explicit auth namespace cheaply:
louise gen-auth-schema --table-prefix auth_ --out drizzle/0002_auth.sqlThe prefix must be a bare SQL identifier (/^[A-Za-z_][A-Za-z0-9_]*$/), and the
same prefix must be set on LouiseAuthConfig.tablePrefix
so the runtime queries the namespaced tables. The optional KV session cache
(sessionCacheKv) is orthogonal and works under either
option.
LouiseAuthEnv
Section titled “LouiseAuthEnv”Extends LouiseEnv with the auth bindings your
Env should satisfy: DB (D1), EMAIL, TURNSTILE_SECRET,
TURNSTILE_SITE_KEY?, OWNER_EMAIL?, ENGINEER_EMAIL?.