Auth Hooks
Run project code on user signup, login, and session events via the quickback/hooks/ folder
Auth Hooks
Quickback discovers project-level auth hooks at quickback/hooks/. Most before-/after- lifecycle hooks wire directly into Better Auth's databaseHooks; before-email-otp-activate.ts is a narrower compiler-owned atomic ceremony, and the resolve-* hooks are trusted server resolvers called from generated middleware (OAuth) or the email send. Use them for post-signup bootstrap, signup gating, tenant resolution, and per-recipient email context.
The classic case is multi-tenant: when account.auth.organizations: true is set, a freshly signed-up user has no activeOrganizationId, so every authenticated /api/v1/* returns 403 ORG_REQUIRED. An after-user-create hook can auto-provision a personal workspace + member row so the next request just works.
Folder layout
quickback/
hooks/
after-user-create.ts
before-user-create.ts
before-email-otp-activate.ts
after-session-create.ts
after-admin-user-create.ts
resolve-oauth-context.ts
resolve-email-context.tsEach file has a single default export — an async (entity, ctx) => ... function. The filename determines which Better Auth event the hook fires on.
Recognized events
| Filename | Better Auth path | Entity | Return |
|---|---|---|---|
before-user-create.ts | databaseHooks.user.create.before | User | void | { data?: Partial<User> } |
after-user-create.ts | databaseHooks.user.create.after | User | void |
before-email-otp-activate.ts | Atomic email-OTP activation before user.update | { userId, email, user } | void | false |
after-session-create.ts | databaseHooks.session.create.after | Session | void |
after-admin-user-create.ts | databaseHooks.user.create.after (filtered) | User | void |
resolve-oauth-context.ts | OAuth bearer middleware | OAuth identity + session | Partial AppContext tenant fields |
resolve-email-context.ts | emailOTP sendVerificationOTP | { email, type, name } | Extra fields merged into the email template context |
after-admin-user-create.ts is a virtual event — it wires into the same Better Auth user.create.after slot as the generic after-user-create.ts, but the compiler wraps the call in a runtime filter on the BA context path so it only fires when the row was created via the admin plugin's POST /admin/create-user endpoint. Use it for first-time-user provisioning (welcome email, default org assignment) without inspecting _baCtx?.context?.path by hand. The generic after-user-create.ts still runs for every signup, including admin-created ones.
Filenames outside this set are a hard compile-time error so a typo never silently disables a hook. Add events by extending apps/compiler/src/parser/hooks.ts (and the matching CLI mirror).
Atomic email-OTP activation
Use before-email-otp-activate.ts when an existing dormant account must pass
project business rules before email verification and session creation become
durable. Quickback owns the transaction and identity proof; your API hook owns
the business decision.
This surface is deliberately explicit and narrow. It requires all of:
contract.version: "v2"- Cloudflare Workers
- one Neon database using
connectionMode: "hyperdrive" - the Better Auth
emailOtpplugin with effectivedisableSignUp: true atomicAuthRoutes: ["emailOtpSignIn"]quickback/hooks/before-email-otp-activate.ts
providers: {
runtime: defineRuntime("cloudflare"),
database: defineDatabase("neon", {
connectionMode: "hyperdrive",
hyperdrive: { id: "your-hyperdrive-id" },
}),
auth: defineAuth("better-auth", {
plugins: { emailOtp: { disableSignUp: true } },
atomicAuthRoutes: ["emailOtpSignIn"],
}),
}The hook receives the proven, pre-update user identity and the transaction-bound full application database:
import { and, eq } from "drizzle-orm";
export default async function beforeEmailOtpActivate(
{ userId, email, user },
{ db, schema, atomicRoute },
) {
// user.emailVerified is authoritatively false here. Keep event/pass/status
// policy in this project API hook; Quickback supplies only the ceremony.
if (atomicRoute !== "emailOtpSignIn" || user.emailVerified !== false) return false;
const [candidate] = await db
.select({ id: schema.admissions.id })
.from(schema.admissions)
.where(and(
eq(schema.admissions.userId, userId),
eq(schema.admissions.email, email),
))
.limit(1);
if (!candidate) return false;
// Any business writes performed through db remain in this same transaction.
}For the exact POST <auth-base>/sign-in/email-otp request, the generated
Worker opens one createServiceDb(env).transaction(...), builds Better Auth
with its Drizzle adapter bound to tx, and supplies that same tx as both
db and authDb. After OTP validation, Quickback proves the target by reading
the normalized request email through the transaction-bound Better Auth adapter.
Only a matching user whose authoritative emailVerified value is false
reaches the hook.
The generated user.update.before wrapper imports and invokes the semantic
hook exactly once. Its db / authDb values retain the transaction's real
Drizzle select type, so hooks can declare a narrow
Pick<DatabaseInstance, "select"> contract without casts.
The hook runs in databaseHooks.user.update.before, before Better Auth takes
the user update lock. That preserves the project's business lock order; a
session-create hook would take the user lock first and can deadlock with an
issuance path that locks application rows before the user.
The transaction commits only after the verification update and session insert succeed. A denied activation, a non-ok response after activation begins, or any 5xx response throws a private rollback sentinel, so OTP consumption, hook writes, user verification, and session creation roll back together. Invalid OTP 4xx responses that never begin activation are not converted to exceptions, so Better Auth's attempt counter still commits. Already-verified users do not rerun the dormant-account hook. OTP sending and every other auth route remain outside this transaction. The atomic wrapper preserves Better Auth's original handler before decorating the auth object, so non-atomic session, account, and plugin requests are forwarded exactly once instead of re-entering the wrapper.
Return false for an expected policy denial. The compiler short-circuits Better
Auth with a private error and maps the rolled-back ceremony to a generic 403;
it never lets Better Auth continue to session creation after a false return.
Unexpected throws are operational failures: the generated handler rolls back,
emits only the metadata event auth.atomic_email_otp_operational_rollback, and
returns a generic 503 without raw error content.
OAuth context resolver
First-class MCP OAuth validates the access token and live Better Auth session
before calling quickback/hooks/resolve-oauth-context.ts. Use this trusted
server hook when tenant selection cannot be derived solely from the session's
active organization.
export default async ({ token, session, user, context }, { authDb, authSchema, env }) => {
return {
activeOrgId: session.activeOrganizationId ?? null,
activeTeamId: session.activeTeamId ?? null,
tenantId: session.activeOrganizationId ?? null,
roles: context.roles,
};
};The returned object is merged into the standard AppContext. The compiler
always preserves the validated authenticated, userId, and user fields,
so this hook cannot replace the OAuth identity.
Email context resolver
Better Auth routes OTP emails by type (sign-in, email-verification,
forget-password), not by audience, and a template only receives fixed
appName / appUrl / supportEmail. When you need per-recipient context —
a branded palette for organizers vs. attendees, a plan tier, a locale —
add quickback/hooks/resolve-email-context.ts. The emailOTP send calls it for
each recipient and spreads the returned object into your email templates.
export default async ({ email, type, name, db, schema, eq, and }) => {
// Is this recipient a member of any organization?
const [u] = await db.select({ id: schema.user.id })
.from(schema.user).where(eq(schema.user.email, email)).limit(1);
const isOrgMember = u
&& (await db.select({ id: schema.member.id })
.from(schema.member).where(eq(schema.member.userId, u.id)).limit(1)).length > 0;
return { audience: isOrgMember ? "organizer" : "attendee" };
};Your email template then reads the extra field:
export default ({ otp, appName, audience }) => ({
subject: `Your ${appName} code`,
html: renderOtp({ otp, theme: audience === "organizer" ? "eggplant" : "blue" }),
text: `Your code is ${otp}`,
});Notes:
- Fail-open. If the hook throws, the context falls back to
{}and the OTP still sends — a hook bug can never lock users out of sign-in. - Spread into user templates only. The packaged default templates have a fixed signature; convert a type to a custom template to consume the extra context.
db,schema,eq, andandare passed in (the same Drizzle handle Better Auth uses) so you can look the recipient up without an import.- This is not a Better Auth
databaseHook— it's consumed only by the emailOTP send, so it adds no signup/session latency.
ctx shape
Every hook receives (entity, ctx):
type HookCtx = {
/** Drizzle client bound to the auth DB binding (split-DB aware). */
authDb: DrizzleClient;
/**
* Auth schema namespace — destructure to access the BA-managed tables
* (`user`, `session`, `organization`, `member`, …) without writing an
* import path. The hook source lives in `quickback/hooks/` pre-compile
* and `src/lib/auth-hooks/` post-stage; relying on `authSchema`
* removes the dual-path import problem.
*/
authSchema: typeof import('../../auth/schema');
/** Cloudflare bindings (Cloudflare runtime) or process.env (Bun/Node). */
env: Bindings;
/** Better Auth's own context object — request, hook metadata, etc. */
baContext: any;
};authDb is the load-bearing piece: hooks must write to the auth DB via Drizzle directly. See the CSRF caveat below. authSchema is the namespace export of the generated auth schema module; destructure the tables you need.
The atomic email-OTP hook additionally receives db (the transaction-bound
full application Drizzle client), schema (the full generated application
schema), and atomicRoute: "emailOtpSignIn". In a single-database Neon project,
db and authDb are the same transaction handle; the two schema namespaces
make project and Better Auth tables explicit.
Writing hooks: use built-ins where possible
Hook source files are evaluated by the Better Auth schema-generation step at compile time, which runs in a stripped-down sandbox that does not see your project's node_modules. A hook that imports @paralleldrive/cuid2, nanoid, or any other third-party package will fail compile with a "module not found" error during the auth-schema-generation step — even if the package is installed and runs fine in the deployed Worker.
Stick to runtime built-ins for ID generation, hashing, and similar primitives:
| Need | Built-in |
|---|---|
| Random ID | crypto.randomUUID() |
| SHA-256 / digest | crypto.subtle.digest(...) (Workers / Node 19+) |
| Random bytes | crypto.getRandomValues(new Uint8Array(n)) |
| Time | Date.now(), new Date().toISOString() |
drizzle-orm is already available in the schema-gen sandbox (the auth schema imports from it), so eq, and, inArray, etc. are safe to import.
Throw semantics
What happens when a hook throws is the #1 thing that bites people:
| Hook | Throw effect |
|---|---|
before-user-create | Signup aborts. The user is not created. Throw a typed error to gate. |
before-email-otp-activate | The entire atomic ceremony rolls back: OTP consumption, hook writes, verification update, and session insert. |
after-user-create | The user is created (the BA insert already committed). Any derived rows your hook started writing become orphans. |
after-session-create | Same as above — the session row exists; partial writes leak. |
For after-* hooks, design for idempotency: use the entity's id as a stable seed for any derived rows so re-running the hook (manually or via a background reconciler) is safe. Don't generate fresh cuid()s for derived rows you'd want to recover.
Examples
Auto-provision a personal workspace on signup
The QB-9 case. Without this, organizations: true projects break immediately for new users.
import { eq } from 'drizzle-orm';
export default async (user, { authDb, authSchema }) => {
const { organization, member } = authSchema;
// Idempotency: derive a stable org id from the user id so re-running this
// hook (after a partial failure) doesn't create a second workspace.
const orgId = `org_${user.id}`;
const slug = `${(user.name ?? 'workspace').toLowerCase().replace(/\s+/g, '-')}-${user.id.slice(0, 6)}`;
// Skip if the workspace already exists (idempotent).
const existing = await authDb
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, orgId));
if (existing.length > 0) return;
await authDb.insert(organization).values({
id: orgId,
name: `${user.name ?? 'Personal'} Workspace`,
slug,
createdAt: new Date(),
});
await authDb.insert(member).values({
id: crypto.randomUUID(),
userId: user.id,
organizationId: orgId,
role: 'owner',
createdAt: new Date(),
});
};Set the active organization on next session
Setting activeOrganizationId from a hook means writing to session.active_organization_id via Drizzle directly — calling Better Auth's setActiveOrganization API server-side is awkward (see the CSRF caveat). The reliable pattern:
import { eq } from 'drizzle-orm';
export default async (newSession, { authDb, authSchema }) => {
const { session, member } = authSchema;
// Pick the user's first membership as their active org. Projects that
// want a sticky last-active-org should track that separately.
const memberships = await authDb
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, newSession.userId))
.limit(1);
if (memberships.length === 0) return;
await authDb
.update(session)
.set({ activeOrganizationId: memberships[0].organizationId })
.where(eq(session.id, newSession.id));
};Normalize and gate signup
before-* hooks can return { data: Partial<User> } to mutate the row before insert. Throw to abort.
export default async (user: { email: string; name?: string }) => {
// Block disposable inboxes.
if (/@(?:mailinator|10minutemail)\.com$/i.test(user.email)) {
throw new Error('Disposable email addresses are not allowed.');
}
// Trim whitespace from display name.
return { data: { name: user.name?.trim() } };
};The compiler-generated wrapper merges your data onto the original entity, so you only return the fields you actually want to change.
CSRF caveat — server-side org create
POST /auth/v1/organization/create (and Better Auth's auth.api.createOrganization(...) server helper) require an Origin header. A hook running inside databaseHooks.user.create.after is server-side — there is no incoming request, so no Origin. Calling those APIs from a hook may also validate Origin internally and reject.
The only safe path is direct Drizzle writes via authDb. Treat the org/member tables as your source of truth and let the SPA call the BA API for user-initiated org creation.
Generated output
Compile picks up quickback/hooks/*.ts and produces:
src/lib/auth.ts # imports the hook + adds a databaseHooks block
src/lib/auth-hooks/<file> # your hook source, copied verbatimInside the generated auth.ts:
import __qbHook_afterUserCreate from "./auth-hooks/after-user-create";
// ...inside createAuth(env)...
return betterAuth({
// ...
database: drizzleAdapter(db, { /* ... */ }),
databaseHooks: {
user: {
create: {
after: async (entity, _baCtx) => {
const _qbCtx = { authDb: db, authSchema: schema, env, baContext: _baCtx };
await __qbHook_afterUserCreate(entity, _qbCtx);
},
},
},
},
});No hooks → no databaseHooks block emitted. The whole feature is opt-in by file presence.
See also
- With Quickback Compiler — how
account.auth.organizationsflows through the build - Configuration → account block —
account.appUrland other account-level options - Better Auth: Database Hooks