# Auth Hooks

Quickback discovers project-level auth hooks at `quickback/hooks/` and wires them into [Better Auth's `databaseHooks`](https://www.better-auth.com/docs/concepts/database#database-hooks). 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.ts
    after-admin-user-create.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`                          |
| `after-session-create.ts`      | `databaseHooks.session.create.after` | `Session` | `void`                          |
| `after-admin-user-create.ts`   | `databaseHooks.user.create.after`  (filtered) | `User` | `void` |

`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).

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

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

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