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.
What a hook can return
Abefore hook returns one of three things.
Only some fields can be replaced. Anything else in
data (including id) stops the write with DATABASE_HOOK_INVALID_PAYLOAD.
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
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
forcePasswordResetflag cleared by a completed password change, and revocation deletes. - GDPR erasure.
gdpr.deleteUser(andadmin.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 withautoDeactivateUsersoff. - Importers.
theauth migrateand 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 devand the demo server seed users directly.
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
beforehook runs ahead of the write it guards. If it aborts, nothing else was changed: a sessionbeforeabort does not evict another session whenmaxConcurrentis set, and a refresh that is aborted leaves the old session usable. - An
afterhook fires only for the caller that won. A refresh firessession.create.afteronce the old session has been claimed, so a refresh that loses the race fires nothing. - A
beforehook 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
beforehook changes nothing. SCIM answers 403. A Stripe or Polar webhook answers 500, so the provider retries the event until your hook lets it through.
after, and keep before to quick checks.
Handling an abort
DatabaseHookError into a 403 response.