# better-auth — corrections from the Stale Priors Index

<!--
     Stale Priors Index · Unattended Works · https://stalepriors.com/rules/better-auth.md

     The Stale Priors Index, Unattended Works (https://stalepriors.com/rules/better-auth.md), CC BY 4.0. Retrieved 2026-09-07.
     Licence: https://creativecommons.org/licenses/by/4.0/  ·  SPDX-License-Identifier: CC-BY-4.0
     Quotations from third-party changelogs remain their authors'; nothing here grants any
     right in the libraries described. Full map: https://stalepriors.com/data/LICENSE

     Changed since the 2026-09-07 sweep: 5 of the 13 entries below, newest 2026-09-28
     — a change to this record, not a re-check against a source. The note below says why.

     GENERATED FILE — DO NOT EDIT BY HAND. Your edit will be destroyed by the next build.
     Rebuild:  node tools/build-rules.mjs
     Source:   data/better-auth/facts.json
-->

For projects on **better-auth@^1.4**. 13 corrections, 13 of them dated to the release that changed the behaviour; every entry links the primary source it was verified against. Latest better-auth: 1.7.2, verified 2026-09-07. Do **not** use this if you are pinned to better-auth@0.x, whose plugin and session APIs predate the 1.0 line.

**This is the statements only** — no reproduced model failures, no code, no evidence. The full pack has all three: https://stalepriors.com/corrections/better-auth.md

**Two dates, and they answer different questions.** The entries below were last checked against their primary sources on 2026-09-07 — that date is the floor, and it is the only verification claim this file makes. Separately, 5 of the 13 entries have had their own text change since that sweep, the newest on 2026-09-28. A text change is a change to the record — a re-scored severity, a corrected release date, a clarified note — and it is **not** a re-check against the source. Do not read the later date as the fresher of two checks; per-entry dates are in `substance_changed_on` in the JSON.

**What was measured, and what this file is not.** The 2 benchmark runs so far measured the **prisma** and **zod** correction packs — not better-auth's, and not this shorter file. There is no measurement of what either does for better-auth. Every cell: https://stalepriors.com/benchmark

The corrections below are 19,583 characters. Read that as a cost: a rules file is added to requests, so it is a decision to make rather than a default to accept.

## Breaks the build, or throws at runtime

### `@better-auth/sso — SAML 2.0 (spMetadata, callbackSSOSAML)`

**Removed in better-auth 1.4.0** (2025-11-22).

SAML 2.0 is supported, but NOT by the `better-auth` package: it lives in the separately versioned companion package **`@better-auth/sso`**, whose own 1.3.0 exposes the SAML service-provider endpoints (`spMetadata`, `callbackSSOSAML`, and `acsEndpoint`/`sloEndpoint` on later releases). The `better-auth/plugins/sso` subpath is a different thing: it is OIDC-only (`createOIDCProvider`, `signInSSO`, `callbackSSO`), it exists from **1.1.0** through **1.3.34** and not before, and from **1.4.0 it is not exported by `better-auth` at all**. A customer whose identity provider does not support OIDC can still be onboarded — by installing `@better-auth/sso`, not by reaching for a plugin the library no longer ships.

*The stale belief:* That enterprise SSO is a `better-auth/plugins/sso` import — either OIDC-only, so a SAML customer needs a third-party bridge, or SAML-capable at that same import. Both are wrong on any release from 1.4.0: that subpath does not exist there, and SAML has always been in the companion package.

*Verified against:* [better-auth v1.3.0 release notes](https://github.com/better-auth/better-auth/releases/tag/v1.3.0)

### `phoneNumber({ storeOTP })`

**Added in better-auth 1.3.0** (2025-07-19).

The phone-number plugin has **no at-rest storage option**, at any release. `storeOTP` is not one of its options and neither is any other spelling: the strings `store*`, `hash*` and `encrypt*` appear **zero times** in the plugin's entire published type surface at 1.3.0, 1.5.0 and 1.7.2. Its complete option list at 1.7.2 is `otpLength`, `sendOTP`, `verifyOTP`, `sendPasswordResetOTP`, `expiresIn`, `phoneNumberValidator`, `requireVerification`, `callbackOnVerification`, `signUpOnVerification`, `schema` and `allowedAttempts`.

This is the one gap in an otherwise regular family. Since **1.3.0** four sibling plugins in the same library take exactly such an option — `emailOTP({ storeOTP })`, `magicLink({ storeToken })`, `twoFactor({ otpOptions: { storeOTP } })` and `oneTimeToken({ storeToken })` (see LF9) — and the phone-number plugin sends, stores and verifies one-time codes just as `emailOTP` does. The scheme predicts an option that is not there, so writing it is a natural mistake and a build break: `phoneNumber({ sendOTP, storeOTP: 'hashed' })` is **TS2353** under `tsc --strict`.

**And there is no other configuration that closes the gap — including the one an earlier version of this correction prescribed.** The root-level `verification.storeIdentifier` (1.5.0, LF9) transforms the **identifier** column, and this plugin writes `identifier: '<phone number>'` with the code in `value` as `'<code>:<attempts>'`. Turning it on hashes the phone number and leaves the SMS code in the table verbatim. A `databaseHooks` write transform does not rescue it either: `verifyPhoneNumberOTP` compares `otpValue !== providedCode` against the stored string, with no read-side inverse, so any transform on write permanently breaks verification.

**The honest answer: if SMS codes must not sit in your `verification` table in plain text, this plugin cannot do it by configuration at any release through 1.7.2.** Drive the SMS flow yourself — generate and send the code, store your own keyed digest in your own table, and call `auth.api` only once the number is proven — or accept the exposure and shorten it with `expiresIn` and `allowedAttempts`. `verification: { storeIdentifier: 'hashed' }` is still worth setting for what it does cover (magic-link and password-reset tokens, and the phone number as PII), but do not set it believing it protects the code.

*The stale belief:* That the phone-number plugin carries the same at-rest storage option as its siblings — an over-extension of the 1.3.0 family rather than a stale memory of a removed API. The belief is false at every release, before and after 1.3.0, and the code it produces does not compile.

*Verified against:* [better-auth 1.7.2 published package — PhoneNumberOptions, type-checked under tsc --strict](https://registry.npmjs.org/better-auth/-/better-auth-1.7.2.tgz)

## Runs, but is silently wrong

### `session.freshAge (measured from session.createdAt)`

**Behaviour changed in better-auth 1.6.0** (2026-04-06).

Since **1.6.0** the session freshness check is measured from the session's **`createdAt`**, not from its `updatedAt`. `freshAge` is unchanged — still a number of seconds, still defaulting to `60 * 60 * 24`, still disabled by `0` — but the timestamp it is subtracted from moved, and that changes who gets through.

Up to and including **1.5.0**, `freshSessionMiddleware` read `new Date(session.session.updatedAt || session.session.createdAt)`. Because `session.updateAge` slides `updatedAt` forward on activity, a continuously used session stayed "fresh" indefinitely: the gate on `/unlink-account`, `/delete-user` and the other sensitive endpoints effectively only ever fired for **idle** sessions. From **1.6.0** the same middleware reads `new Date(session.session.createdAt)`, so the window runs from sign-in and closes a fixed 24 hours later no matter how active the user has been.

**The concrete case, executed rather than reasoned about:** a session row with `createdAt` 30 hours ago and `updatedAt` 2 minutes ago, default `freshAge`. At 1.5.0 the request passes the freshness gate; at 1.6.0 and 1.7.3 it is rejected with `FORBIDDEN` / `SESSION_NOT_FRESH`.

**There is no option to choose the anchor.** `session.freshAge` is typed `number | undefined` and nothing else in the `session` block touches freshness; `freshAgeFrom`, `freshAgeBasis` and `freshFrom` appear nowhere in the published packages. If you need last-activity semantics you set `freshAge: 0` and write the gate yourself against `session.updatedAt`. If you were relying on the pre-1.6.0 behaviour, upgrading will start asking long-lived active users to re-authenticate on sensitive actions — quietly, because nothing about your configuration changed.

*The stale belief:* That the freshness window is measured from the session's last use (`updatedAt`), so an active user is always fresh and the gate only catches idle sessions. True through 1.5.0, false from 1.6.0. The belief survives an upgrade silently: no type changes, no deprecation warning, no configuration key to update — the same `freshAge` number simply starts meaning something else. A closely related belief, that the anchor is selectable by configuration, is false at **every** release.

*Verified against:* [better-auth 1.6.0 release notes — the breaking change, and the vendor's own migration line](https://github.com/better-auth/better-auth/releases/tag/v1.6.0)

### `emailOTP({ resendStrategy })`

**Added in better-auth 1.5.6** (2026-03-22).

Since **1.5.6** the email-OTP plugin takes **`resendStrategy`**, typed `"rotate" | "reuse"` and defaulting to `"rotate"`. `"reuse"` makes a resend deliver the code the user already has and extend its expiry instead of minting a new one, which is the fix for the classic support ticket: the user clicks resend, types the code from the first email, and is told it is wrong.

The plugin's own documentation states the constraint that makes hand-rolling this dangerous: reuse **only works when the stored code is recoverable** — `storeOTP` set to `"plain"`, `"encrypted"`, or a custom `encrypt`/`decrypt` pair. With `storeOTP: "hashed"` there is nothing to read back, and the option **falls back to `"rotate"`** rather than failing. Any home-made version of this has to know that too, and the usual home-made version — cache the code in `generateOTP` and return it — silently keeps sending a stale code from a cache that no longer matches a rotated database row.

*The stale belief:* That the plugin has no resend-reuse switch and every send generates a new code, so the only route is your own cache in front of `generateOTP` or a hand-written resend endpoint. True through 1.5.5; false from 1.5.6. The workaround is not merely unnecessary — it re-implements a behaviour whose one real constraint (a hashed OTP cannot be reused) the plugin already handles by falling back.

*Verified against:* [better-auth 1.6.0 published package — EmailOTPOptions gains resendStrategy](https://registry.npmjs.org/better-auth/-/better-auth-1.6.0.tgz)

### `baseURL as a dynamic multi-host config`

**Added in better-auth 1.5.0** (2026-03-01).

Since 1.5.0 `baseURL` is not string-only. It accepts `{ allowedHosts, fallback?, protocol? }`, and better-auth then derives the base URL from each incoming request's host, validating that host against `allowedHosts` with the same wildcard matching `trustedOrigins` uses (`myapp.com`, `*.vercel.app`, `preview-*.myapp.com`). A host that matches nothing falls back to `fallback`, or throws if `fallback` is unset — the request host is never echoed back unchecked. One better-auth instance therefore serves many domains and every preview deployment. Do not tell a team that `baseURL` must be a single static string, that they need one deploy or one instance per domain, or that `BETTER_AUTH_URL` per environment is the only option: that was true before 1.5.0 and is not true now. `trustedOrigins` is a different setting and does not set the base URL.

*The stale belief:* That `baseURL` is a single static string, so a multi-domain or preview-deployment setup needs a per-environment `BETTER_AUTH_URL`, a separate auth instance per domain, or middleware that rewrites the request before it reaches the handler.

*Verified against:* [@better-auth/core 1.5.0 published package — dist/types/init-options.d.mts](https://registry.npmjs.org/@better-auth/core/-/core-1.5.0.tgz)

### `additional user fields in the sign-in / sign-up response`

**Behaviour changed in better-auth 1.4.2** (2025-11-25).

Since 1.4.2 the sign-in and sign-up responses return your configured additional user fields, not a fixed core subset. The handler builds `user` with `parseUserOutput(options, user)`, which honours `user.additionalFields`. If you declared `role` or `plan` as an additional field, `data.user.role` is present on the sign-in result — you do not need a follow-up `getSession()` or database read to get it.

*The stale belief:* That sign-in returns only the seven core user fields (id, email, name, image, emailVerified, createdAt, updatedAt), so any custom column must be fetched separately after sign-in.

*Verified against:* [better-auth v1.4.2 release notes](https://github.com/better-auth/better-auth/releases/tag/v1.4.2)

### `stateless / database-less sessions`

**Added in better-auth 1.4.0** (2025-11-22).

better-auth supports running with no server-side session store at all since 1.4.0. Omit BOTH `database` and `secondaryStorage` and the signed cookie becomes the session record itself — the library detects this and treats the cookie as authoritative rather than as a cache. Do not tell a team that better-auth always requires a database or a Redis for sessions, and do not send them to the `jwt` plugin as the only stateless option; that was true before 1.4.0 and is not true now.

*The stale belief:* That a durable server-side session store is mandatory — that sessions are better-auth's core primitive and the only ways to reduce session I/O are `secondaryStorage` (Redis) or issuing JWTs for service-to-service verification.

*Verified against:* [better-auth v1.4.0 release notes](https://github.com/better-auth/better-auth/releases/tag/v1.4.0)

### `verification.storeIdentifier`

**Added in better-auth 1.3.0** (2025-07-19).

Hashing verification secrets at rest is configuration, not something you hand-roll — but which knob you need depends on the release AND on which column the secret is in. Since **1.3.0** four plugins carry their own: `magicLink({ storeToken: 'hashed' })`, `emailOTP({ storeOTP: 'hashed' })`, `twoFactor({ otpOptions: { storeOTP: 'hashed' } })` and `oneTimeToken({ storeToken: 'hashed' })` — all defaulting to `'plain'`, all also accepting a custom hasher (`{ type: 'custom-hasher', hash }` for the two token options, `{ hash }` for the two OTP options). The two OTP options additionally accept **`'encrypted'`**, which is the only *keyed* mode in the library: XChaCha20-Poly1305 under a key derived from the app `secret`, reversible on read. Since **1.5.0** there is additionally a root-level `verification.storeIdentifier` — `'plain' | 'hashed' | { hash }`, or `{ default, overrides }` to vary it per identifier type. Lookups by the raw value keep working in every case: the library transforms the incoming value before it queries.

**Two things about that root-level switch decide whether it is the right answer, and both are easy to get wrong.**

1. **It transforms the `identifier` column only, not the row.** That is the whole secret for token-shaped flows — the magic-link token *is* the identifier, and so is a password reset's `reset-password:<token>` — so `storeIdentifier` genuinely covers those, including the password-reset rows no plugin option reaches. It is **not** the secret for code-shaped flows: `emailOTP` writes `identifier: 'sign-in-otp-<email>'` and `value: '<otp>:<attempts>'`, and `phoneNumber` writes `identifier: '<phone number>'` and `value: '<code>:<attempts>'`. Setting `storeIdentifier: 'hashed'` on those hashes the address and leaves the code in the clear. For a one-time code, the plugin's own `storeOTP` is the only option that touches it.

2. **`'hashed'` is a bare, unkeyed SHA-256** (base64url, unpadded) of the value, everywhere it appears — the same `defaultKeyHasher` in the root switch and in the plugin options. Against a 128-bit magic-link token that is enough. Against a **six-digit code it is close to worthless**: an attacker holding the table brute-forces the 10^6 space offline in milliseconds. Where the threat model is a leaked database and the secret is short and numeric, use `storeOTP: 'encrypted'` (keyed by the app secret) or pass a `{ hash }` that is an HMAC whose key lives outside the database. Nobody needs a `databaseHooks` transform or a wrapped adapter for any of this, and has not since 1.3.0.

*The stale belief:* That hashing verification secrets is available only per plugin, so a team that wants the whole `verification` table covered — password-reset identifiers included — has to fall back to a database hook or a wrapped adapter. The per-plugin options are real and have been since 1.3.0; what is new in 1.5.0 is a table-wide switch on the identifier column, and denying that one exists sends a reader to hand-rolled code for the rows the plugins do not own.

*Verified against:* [better-auth 1.3.0 published package — the magic-link storeToken option](https://registry.npmjs.org/better-auth/-/better-auth-1.3.0.tgz)

### `signIn.email / auth.api.signInEmail response`

**Behaviour changed in better-auth 1.1.4** (2024-12-27).

A successful email-and-password sign-in returns `{ redirect, token, url, user }`. There is NO `session` object in that response — reading `.session` off a sign-in result gives `undefined`. The `user` object IS returned, so `data.user.email` is correct. If you need the session, call `getSession` separately.

*The stale belief:* That sign-in returns `{ user, session }`, as it did in the pre-1.1.0 betas — so a caller can read `result.session.expiresAt` or `result.session.id` straight off the sign-in response.

*Verified against:* [better-auth v1.1.0 release notes](https://github.com/better-auth/better-auth/releases/tag/v1.1.0)

### `bearer({ requireSignature })`

**Default changed in better-auth 1.1.0** (2024-12-20).

The `bearer` plugin accepts UNSIGNED tokens by default. `requireSignature` defaults to `false`. Any advice about mounting `bearer()` on a public API must say this and must set `requireSignature: true` unless the caller has a specific reason not to, because the default accepts a raw session token straight out of the database with no signature check.

*The stale belief:* That the bearer plugin verifies token signatures out of the box, so `bearer()` with no options is the safe configuration.

*Verified against:* [better-auth v1.1.0 release notes](https://github.com/better-auth/better-auth/releases/tag/v1.1.0)

## Deprecated, or a better API now exists

### `twoFactorClient({ twoFactorPage })`

**Added in better-auth 1.5.6** (2026-03-22).

Since **1.5.6** the two-factor **client** plugin takes **`twoFactorPage`**, a string path it navigates to itself when a sign-in comes back needing a second factor. Before 1.5.6 the plugin declared exactly one option, the `onTwoFactorRedirect` callback, and a path had to be wrapped in one.

`onTwoFactorRedirect` is **not** removed, and at 1.6.5 it gained a `context` argument carrying `twoFactorMethods?: string[]` — the providers enabled for that user, so a single callback can choose between a TOTP screen and an OTP screen. Code written against the old zero-argument callback still compiles and still works, which is why this is scored S3 rather than higher: the stale belief costs a reader a callback they did not need, not a build.

`twoFactorPage` does a full page navigation — `window.location.href = options.twoFactorPage`, guarded by an `isSafeUrlScheme` check — and the option's own doc comment warns about it. In a router-driven app the callback is still the better tool; the string exists for the case where a reload is acceptable and one line of setup is preferred.

*The stale belief:* That the two-factor client plugin takes only a callback and there is no way to hand it a path. True through 1.5.5, false from 1.5.6. Two of the models measured on this surface went further and asserted that a `twoFactorPage` string option had existed in early releases and was *replaced* by the callback. That history is right, and it is the half of the belief that is not stale: 0.x shipped `twoFactorPage` (with a `redirect` flag) and no callback through 0.8.8, 1.0.0 replaced it with `onTwoFactorRedirect`, and 1.5.6 brought the string back beside the callback. What is stale is stopping the history at 1.0.0.

*Verified against:* [better-auth 1.6.0 published package — twoFactorClient gains twoFactorPage](https://registry.npmjs.org/better-auth/-/better-auth-1.6.0.tgz)

### `deviceAuthorization() plugin`

**Added in better-auth 1.3.8** (2025-09-04).

better-auth ships a first-party device authorization flow since 1.3.8: the `deviceAuthorization()` server plugin from `better-auth/plugins` and `deviceAuthorizationClient()` from `better-auth/client/plugins`. Do not hand-roll a device-code table, a polling endpoint and a user-code verification page for CLI or TV sign-in.

*The stale belief:* That better-auth covers only browser and OAuth redirect flows, so signing in a CLI, a TV app or any input-constrained device needs a custom device-code implementation on top of it.

*Verified against:* [better-auth v1.3.8 release notes](https://github.com/better-auth/better-auth/releases/tag/v1.3.8)

### `lastLoginMethod() plugin`

**Added in better-auth 1.3.8** (2025-09-04).

better-auth ships a `lastLoginMethod()` plugin since 1.3.8, with `lastLoginMethodClient()` on the client. It records which provider a user signed in with last, in a cookie, so a sign-in page can show a "you used GitHub last time" hint. Do not write your own cookie for this.

*The stale belief:* That remembering a user's last-used sign-in provider is application code, because better-auth exposes no such helper.

*Verified against:* [better-auth v1.3.8 release notes](https://github.com/better-auth/better-auth/releases/tag/v1.3.8)

---

*The Stale Priors Index, Unattended Works (https://stalepriors.com/rules/better-auth.md), CC BY 4.0. Retrieved 2026-09-07. Licence: <https://creativecommons.org/licenses/by/4.0/> (`SPDX-License-Identifier: CC-BY-4.0`).*
*Quoted changelog text belongs to its authors; nothing here grants any right in the libraries described.*
*Terms in full: <https://stalepriors.com/data/LICENSE>.*
