> ## Documentation Index
> Fetch the complete documentation index at: https://docs.theauth.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Database hooks

> Run code before and after a user, session or account is created or updated. Abort the write or replace fields.

`databaseHooks` lets you run your own code around the rows theAuth writes for `user`, `session` and `account`. The shape follows Better Auth's `databaseHooks`, so a hook you wrote there moves over with small changes.

```ts theme={"dark"}
import { createTheAuth } from "@glinr/theauth";

const theauth = await createTheAuth({
  database: { provider: "sqlite", url: "theauth.db" },
  auth: { session: { secret: process.env.SESSION_SECRET! } },
  databaseHooks: {
    user: {
      create: {
        // Runs before the row is written.
        before: async (user) => {
          if (!user.email.endsWith("@example.com")) return false; // abort
          return { data: { name: user.name ?? "New user" } }; // replace fields
        },
        // Runs after the row is committed.
        after: async (user) => {
          await sendWelcomeEmail(user.email);
        },
      },
    },
    session: {
      create: {
        before: async (session) => ({
          data: { expiresAt: new Date(session.expiresAt.getTime() - 60_000) },
        }),
      },
    },
  },
});
```

## What a hook can return

A `before` hook returns one of three things.

| Return | Effect |
| - | - |
| `undefined` | The write goes ahead unchanged. |
| `false` | The write is stopped. The caller gets a `DatabaseHookError` with code `DATABASE_HOOK_ABORTED`. |
| `{ data }` | The listed fields replace the ones about to be written. |

Only some fields can be replaced. Anything else in `data` (including `id`) stops the write with `DATABASE_HOOK_INVALID_PAYLOAD`.

| Model | Fields you can replace |
| - | - |
| `user` | `email` (lowercased and checked again), `name`, `metadata` |
| `session` | `expiresAt`, `metadata` |
| `account` | `expiresAt` |

Hooks never see OAuth access or refresh tokens. Session metadata keys that theAuth owns (the auth event history, the idle activity stamp and the remember me flag) are kept as computed, so a hook cannot forge a stronger login.

## Where hooks fire

| Model | Create | Update |
| - | - | - |
| `user` | `theauth.user.create`, magic link, email OTP, phone, OAuth, anonymous, username, Google One Tap and SCIM sign-up | Admin ban, unban and force password reset, additional fields, email verification, anonymous upgrade, SCIM replace, patch and deactivate, Stripe and Polar subscription writes |
| `session` | `sessions.create`, including the new session of a refresh | `recordAuth` (step-up) |
| `account` | `linkAccount` when a provider account is linked | Token refresh on a repeat sign-in, and a repeat `linkAccount` |

On a user update, `before` sees the row as it would be stored. A write that changes other columns (the ban flag, a billing column) still shows the hook the same four fields, and anything the hook replaces is written along with the change. Replacing `email` on an anonymous upgrade or a SCIM write is checked against the unique index like any other email.

Two paths keep the hook's say limited so a hook cannot break sign-in. Phone sign-up always keeps the phone number in `metadata`, and anonymous sign-up always keeps the `anonymous` flag.

### What does not fire hooks

These writes are deliberate bypasses, not gaps.

* **Bookkeeping.** The idle activity stamp on a session, a pending OAuth row that has no user yet, the `forcePasswordReset` flag cleared by a completed password change, and revocation deletes.
* **GDPR erasure.** `gdpr.deleteUser` (and `admin.deleteUser`, which calls it) and the erasure marker and tombstone rows it writes. An erasure request has to complete, so a hook can neither veto it nor rewrite what it leaves behind. Hard deletes are not hook operations in any path, including a SCIM DELETE with `autoDeactivateUsers` off.
* **Importers.** `theauth migrate` and the importer stores insert and update users directly, in bulk, with the ids of the source system. They do not call hooks. If you need a check on imported users, run it over the table afterwards.
* **Dev tooling.** `theauth dev` and the demo server seed users directly.

Writes that bypass hooks on purpose never fire `after` either, so do not rely on `user.create.after` to see every row in `theauth_users`.

## Failure behavior

`before` hooks fail closed. A throw, a timeout or a malformed return value stops the write with a `DatabaseHookError` (`DATABASE_HOOK_FAILED`, `DATABASE_HOOK_TIMEOUT` or `DATABASE_HOOK_INVALID_PAYLOAD`), whatever `hookSafety.onError` is set to. The error message is generic, so it does not leak what your hook checked.

`after` hooks run once the row exists, so they cannot undo it. A throw or timeout is logged through `logger.warn` and swallowed.

The timeout comes from `hookSafety.timeoutMs` or `THEAUTH_HOOK_TIMEOUT_MS` (default 2000 ms). Each hook gets an `AbortSignal` in `ctx.signal` that fires when it runs out of time.

Hooks receive a copy of the data. Changing the argument does not change what is stored, return `{ data }` instead.

## Single-use claims stay atomic

Magic link tokens, OTP codes, OAuth state and refresh rotation all consume something with one conditional statement so that only one concurrent caller wins. Hooks never run inside that statement.

* A `before` hook runs ahead of the write it guards. If it aborts, nothing else was changed: a session `before` abort does not evict another session when `maxConcurrent` is set, and a refresh that is aborted leaves the old session usable.
* An `after` hook fires only for the caller that won. A refresh fires `session.create.after` once the old session has been claimed, so a refresh that loses the race fires nothing.
* A `before` hook that aborts a sign-up that already consumed a code (for example an OTP verify that creates the user) leaves that code consumed. The user retries with a new code. This is deliberate: the claim has to happen first so that only one caller can win, and putting the hook ahead of it would let a slow or hostile hook hold a code open for a second guess. Under concurrent verifies of one code, the hook runs once, for the caller that won the claim.
* A SCIM request or billing webhook stopped by a `before` hook changes nothing. SCIM answers 403. A Stripe or Polar webhook answers 500, so the provider retries the event until your hook lets it through.

Run slow work (email, webhooks, analytics) in `after`, and keep `before` to quick checks.

## Handling an abort

```ts theme={"dark"}
import { DatabaseHookError } from "@glinr/theauth";

try {
  await theauth.user.create({ email: "someone@other.test" });
} catch (err) {
  if (err instanceof DatabaseHookError && err.code === "DATABASE_HOOK_ABORTED") {
    // err.model is "user", err.operation is "create"
  }
}
```

The OAuth callback turns a `DatabaseHookError` into a 403 response.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.