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/ and wires them into Better Auth's databaseHooks. Use them for post-signup bootstrap, signup gating, and other lifecycle work.
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 400 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
after-session-create.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 |
after-session-create.ts | databaseHooks.session.create.after | Session | void |
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).
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.
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. |
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