# Auth Hooks

Quickback discovers project-level auth hooks at `quickback/hooks/`. Most `before-`/`after-` lifecycle hooks wire directly into [Better Auth's `databaseHooks`](https://www.better-auth.com/docs/concepts/database#database-hooks); `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.ts
```

Each 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`](https://github.com/paulstenhouse/quickback-compiler) (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:

- Cloudflare Workers
- one Neon database using `connectionMode: "hyperdrive"`
- the Better Auth `emailOtp` plugin with effective `disableSignUp: true`
- `atomicAuthRoutes: ["emailOtpSignIn"]`
- `quickback/hooks/before-email-otp-activate.ts`

```ts title="quickback/quickback.config.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:

```ts title="quickback/hooks/before-email-otp-activate.ts"
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.

```ts title="quickback/hooks/resolve-oauth-context.ts"
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**.

```ts title="quickback/hooks/resolve-email-context.ts"
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](/platform/auth/plugins/aws-ses#per-type-template-overrides) then reads the extra field:

```ts title="quickback/email-templates/sign-in.ts"
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](/platform/auth/plugins/aws-ses#per-type-template-overrides) to consume the extra context.
- `db`, `schema`, `eq`, and `and` are 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)`:

```ts
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](#csrf-caveat-server-side-org-create) below. `authSchema` is the namespace export of the generated auth schema module; destructure the tables you need.

This injection is unique to account hooks. Actions reach AUTH_DB via `c.get("authDb")` ([Scoped DB](/define/actions/scoped-db#reaching-better-auth-tables-authdb)). Trigger handlers on a table file get neither — use `ctx` for the acting user, and do not import `../../auth/schema` from the table file.

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.

```ts title="quickback/hooks/after-user-create.ts"
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:

```ts title="quickback/hooks/after-session-create.ts"
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.

```ts title="quickback/hooks/before-user-create.ts"
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 verbatim
```

Inside the generated `auth.ts`:

```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](/ui/account/with-quickback) — how `account.auth.organizations` flows through the build
- [Configuration → account block](/configure) — `account.appUrl` and other account-level options
- [Better Auth: Database Hooks](https://www.better-auth.com/docs/concepts/database#database-hooks)
