better-auth correction pack · for projects on better-auth@^1.4
What we actually measured
5 Claude models were asked for idiomatic better-auth code with no tools, purely from training knowledge. 13 reproduced failures across 32 runs, each verified against the release that broke the belief.
| Model | Stated cutoff | better-auth version attribution stops | Lag inside the window |
|---|---|---|---|
| Claude Opus 5 | 2026-05 | 1.2.0 · 2025-03-01 | ~26 months |
| Claude Fable 5 | 2026-01 | 1.3.0 · 2025-07-19 | ~18 months |
| Claude Sonnet 5 | 2026-01 | 1.0.0 · 2024-11-23 | ~14 months |
| Claude Fable 5.1 | 2026-06 | 1.4.0 · 2025-11-22 | ~7 months |
A recent cutoff is not a defence. Every model here loses track of this library's release history well before the date it states as its own cutoff, and the stopping points cluster far tighter than the cutoffs do.
Read that column precisely. It is the newest better-auth release whose contents the model can correctly attribute to that release — not the newest better-auth feature it knows. Past that point a model will often write working code with a newer API while naming the wrong release for it, and that guess runs early — it names a release older than the one that shipped the feature. So the question this pack answers is not "does the model know this API" but "can it be trusted about which version the API arrived in" — which is the question that matters when you are pinned to a version.
How to read an entry
Every entry ends with a Reproduced against line. Where it names models, we have the generated code that got it wrong, dated, with the model's own words in the run write-up. Where it says no model yet, the correction is verified from the release notes but nothing has been probed for it — it is a fix, not a measurement, and the pack says so rather than blurring the two.
The section an entry sits in is the worst case if you act on the stale belief. The severity in brackets after a model's name is what that particular model's output actually did, which can be milder — a model can hold the wrong belief and still, on the day, write code that runs.
The corrections
Breaks the build, or throws at runtime
Act on the stale belief here and the code does not run. Fix these first.
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.
The code below needs better-auth 1.5.0 or later. Between 1.3.0 and 1.5.0 this correction does not apply — see the note.
// Stale
// does not compile at any release:
// TS2353: Object literal may only specify known properties,
// and 'storeOTP' does not exist in type 'PhoneNumberOptions'.
export const auth = betterAuth({
plugins: [
phoneNumber({
sendOTP: async ({ phoneNumber, code }) => sendSms(phoneNumber, code),
storeOTP: 'hashed',
}),
],
})
// Current
// The plugin takes no at-rest storage option, and no root-level option covers
// the CODE — `verification.storeIdentifier` transforms the identifier column,
// which for this plugin is the phone number, not the code. See LF9.
export const auth = betterAuth({
verification: {
// worth setting for what it DOES cover: magic-link and password-reset
// tokens (the token is the identifier), and the phone number as PII.
storeIdentifier: 'hashed',
},
plugins: [
phoneNumber({
sendOTP: async ({ phoneNumber, code }) => sendSms(phoneNumber, code),
otpLength: 6,
// no storage option exists; bound the exposure instead
expiresIn: 300,
allowedAttempts: 3,
}),
],
})
// If the code must not be readable in the database at all, the plugin's own
// flow cannot give you that at any release through 1.7.2 — own the SMS step:
// generate the code, store a keyed digest in your own table, and call
// auth.api only once the number is proven.
A NEGATIVE CLAIM, and the second one in the dataset after tailwindcss LF30 — see BACKLOG item 2h. The Node auditor has no concept of a claim that a name is absent, so the absence half of this fact was verified by hand rather than through the auditor's own rules, and is recorded here in the form a future 'asserted-absent names' auditor field would need.
introduced_in is 1.3.0 for a reason worth stating plainly: the claim itself is version-independent — there is no release at which the phone-number plugin gained or lost an at-rest storage option — and 1.3.0 is recorded because it is the release that created the four-plugin family this belief over-extends. Nothing in the fact turns on a subject's cutoff. replacement_available_from is 1.5.0 because the prescribed answer, verification.storeIdentifier, does not exist below it.
VERIFIED BY TYPE-CHECK, 2026-09-03, against three installed releases. One file configures five plugins and is compiled under tsc --strict (typescript 5.9.3, moduleResolution: bundler, skipLibCheck). At 1.5.0 and 1.7.2 the compiler reports exactly one error, TS2353 on phoneNumber's storeOTP, while emailOTP({ storeOTP }), magicLink({ storeToken }), twoFactor({ otpOptions: { storeOTP } }) and oneTimeToken({ storeToken }) are all clean in the same file. At 1.3.0 the same TS2353 is reported, and so is an unrelated TS2322 on the twoFactor(...) element, whose endpoint types are not assignable to BetterAuthPlugin in that release; that error was isolated rather than assumed — twoFactor({}) with no options reproduces it — and it touches nothing this fact claims.
VERIFIED BY ABSENCE, same session and the stronger half: a scan of the phone-number plugin's published type surface for store*, hash* and encrypt* returns zero matches at 1.3.0, 1.5.0 and 1.7.2. That covers the claim under any spelling rather than only the one name the compiler was asked about. allowedAttempts is present in the plugin's types as far back as 1.2.12, so the plugin's own option surface predates the 1.3.0 storage family and is not empty — the gap is specific to storage.
PRESCRIPTION CORRECTED 2026-09-03 (JOURNAL/047), the session after this fact was written, and the correction is to the half of it nobody had checked. As first written, the statement and correct_code told a reader that verification: { storeIdentifier: 'hashed' } was the way to keep SMS codes out of the table. It is not. storeIdentifier transforms the IDENTIFIER column and this plugin puts the phone number there and the code in value. Executed at an installed 1.7.2 on the memory adapter, with the switch ON and no plugin options set: the row is identifier eo4brUJBb5o2Tie1SU8kmQkN7zk3E0Tf45kV88XdGnU — exactly sha256('+15550001111'), base64url unpadded — and value 668217:0, which is the code that was delivered to sendOTP, verbatim. The advice hashed the phone number and left the secret alone.
Nor is there a fallback. verifyPhoneNumberOTP reads the row and compares otpValue !== providedCode as strings; nothing inverts a transform on the read side, so a databaseHooks hashing hook on write breaks verification for every user rather than securing anything. The absence is total: no plugin option, no root option, no hook. That is what the statement now says.
The negative claim this fact is built on — that phoneNumber takes no at-rest storage option — is UNCHANGED and still verified two ways (TS2353 at three releases; zero store*/hash*/encrypt* matches in the plugin's type surface). The charged finding it corrects (better-auth/v5-a F1, the Index's first invention) is unaffected: a subject inventing storeOTP on this plugin is wrong at every release either way. What changed is only what we tell the reader to write instead — which is the third time a battery's own transcript has corrected one of our corrections (JOURNAL/036, /044, and now /047), and the first time the objection came from the subjects rather than from a re-read.
Reproduced against: Claude Opus 5 (S1) — better-auth/v5-a, 2026-09-03.
Source: better-auth 1.7.2 published package — PhoneNumberOptions, type-checked under tsc --strict · better-auth 1.5.0 published package — the same single error, siblings clean in the same file · 2026-03-01 · better-auth 1.3.0 published package — the release that gave four sibling plugins the option, and not this one · 2025-07-19 · better-auth 1.2.12 published package — allowedAttempts already present, no storage option · better-auth 1.7.2 published package — the phone-number plugin writes the code to the value column · better-auth 1.7.2 published package — verification compares the stored value as a plain string
Runs, but is silently wrong
Nothing errors. The behaviour is simply not what a model trained earlier will tell you.
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.
// Stale
// stale: assumes a session store is compulsory
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
secondaryStorage: redisStore, // "the only way to keep sessions out of the DB"
session: { storeSessionInDatabase: false },
})
// Current
// stateless: no database, no secondary storage.
// The signed session cookie IS the session record.
export const auth = betterAuth({
emailAndPassword: { enabled: true },
// no `database`, no `secondaryStorage`
})
Verified at both ends. Introduction: the 1.4.0 release notes list "Stateless session management". Current behaviour: in the published better-auth 1.7.2 tarball,
dist/context/store-capabilities.mjsdefineshasServerSessionStore(options) { return !!options.database || !!options.secondaryStorage }, anddist/api/routes/session.d.mtsdocuments the resultingisStatefulbranch as choosing between the cookie being "merely an optimization" and it being "the only place the session lives and therefore the authority itself (false, for stateless / DB-less deployments)". What was NOT verified: that the feature is unchanged between 1.4.0 and 1.7.2 — only that it was introduced in 1.4.0 and exists in 1.7.2.storeSessionInDatabaseandpreserveSessionInDatabasedo still exist and are unrelated to this fact; they govern thesecondaryStoragecase.
Reproduced against: Claude Fable 5 (S2), Claude Opus 5 (S2), Claude Sonnet 5 (S2) — better-auth/v1, 2026-09-01; better-auth/v2, 2026-09-02; better-auth/v2-a, 2026-09-02.
Source: better-auth v1.4.0 release notes · 2025-11-22 · better-auth 1.7.2 published package — dist/api/routes/session.d.mts · 2026-08-26
emailOTP({ resendStrategy })
Added in better-auth 1.6.0 (2026-04-06)
Since 1.6.0 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.0; false from 1.6.0. 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.
// Stale
// The pre-1.6.0 workaround. Works, but it is your cache to keep correct,
// and it silently does the wrong thing when storeOTP is 'hashed'.
emailOTP({
otpLength: 6,
expiresIn: 300,
async generateOTP({ email, type }) {
const existing = await redis.get(`otp:${type}:${email}`)
if (existing) return existing
const otp = String(Math.floor(Math.random() * 1e6)).padStart(6, '0')
await redis.set(`otp:${type}:${email}`, otp, 'EX', 300)
return otp
},
async sendVerificationOTP({ email, otp }) { await sendMail(email, otp) },
})
// Current
// 1.6.0 and later — one option, and it knows about the hashed case.
emailOTP({
otpLength: 6,
expiresIn: 300,
resendStrategy: 'reuse', // 'rotate' is the default
storeOTP: 'plain', // reuse needs a recoverable code; 'hashed' falls back to 'rotate'
async sendVerificationOTP({ email, otp }) { await sendMail(email, otp) },
})
VERIFIED AGAINST THE SHIPPED ARTIFACT, 2026-09-06, in
dist/plugins/email-otp/types.d.mtsof three installed releases. The option is absent at 1.5.0 and present at 1.6.0 and 1.7.3, and it is the only difference between the two option lists — 1.5.0 declaressendVerificationOTP,otpLength,expiresIn,generateOTP,sendVerificationOnSignUp,disableSignUp,allowedAttempts,storeOTP,changeEmail,overrideDefaultEmailVerificationandrateLimit; 1.6.0 declares that list plusresendStrategy. The introducing release is therefore exact rather than inferred from a note.
The "reuse" semantics and the hashed-OTP fallback are quoted from the option's own doc comment in the published .d.mts, not from the website, and dist/plugins/email-otp/routes.mjs is the second file in the package that mentions the option.
Reproduced against: Claude Fable 5.1 (S2), Claude Opus 5 (S2) — better-auth/v7-a, 2026-09-06; better-auth/v7-c, 2026-09-06.
Source: better-auth 1.6.0 published package — EmailOTPOptions gains resendStrategy · 2026-04-06 · better-auth 1.5.0 published package — the same option list without resendStrategy · 2026-03-01 · better-auth 1.6.0 release notes — the feature line · 2026-04-06
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.
// Stale
// Reasoning that was correct through 1.5.0 and is wrong from 1.6.0:
//
// createdAt: 30 hours ago
// updatedAt: 2 minutes ago <- the row was touched, so the session is fresh
// => POST /unlink-account succeeds
//
// It does not. From 1.6.0 the middleware never looks at updatedAt:
// FORBIDDEN / SESSION_NOT_FRESH
export const auth = betterAuth({
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24, // slides updatedAt forward — no longer relevant to freshness
freshAge: 60 * 60 * 24,
},
})
// Current
// 1.6.0 and later: freshAge is measured from createdAt. The window runs
// from sign-in, so pick a number you are happy asking an ACTIVE user to
// re-authenticate after.
export const auth = betterAuth({
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
freshAge: 60 * 60 * 24, // 24h from SIGN-IN, not from last use
},
})
// If you want the pre-1.6.0 semantics (fresh while the session is being
// used), there is no option for it. Turn the built-in check off and gate
// the sensitive paths yourself:
export const auth2 = betterAuth({
session: { freshAge: 0 }, // 0 disables the check entirely
})
// ...then, in your own middleware on the endpoints you care about:
// const age = Date.now() - new Date(session.session.updatedAt).getTime()
// if (age > WINDOW_MS) throw new APIError('FORBIDDEN', { message: '...' })
A SEMANTIC change with no name attached — the first one in this dataset. Nothing was added, removed or renamed, so a type-check cannot see it, an auditor that greps for API names cannot see it, and a reader upgrading through 1.6.0 has nothing in their own code to change. It is exactly the shape of stale prior the Index exists to catch and the hardest one to catch any other way.
VERIFIED BY SOURCE READ, 2026-09-06, in dist/api/routes/session.mjs of three installed releases: 1.5.0: const lastUpdated = new Date(session.session.updatedAt || session.session.createdAt).getTime(); if (!(Date.now() - lastUpdated < freshAge * 1e3)) throw ... SESSION_NOT_FRESH 1.6.0: const createdAt = new Date(session.session.createdAt).getTime(); if (Date.now() - createdAt >= freshAge) throw ... SESSION_NOT_FRESH 1.7.3: identical to 1.6.0. dist/context/create-context.mjs sets the default to 3600 * 24 at all three, so only the anchor moved.
VERIFIED BY EXECUTION, same session, because the claim is behavioural (HARNESS: execute the claim against the installed package). One script per release: sign up over auth.api.signUpEmail against the memory adapter, rewrite the stored session row to createdAt = now - 30h and updatedAt = now - 2min, then call auth.api.unlinkAccount — one of the endpoints guarded by freshSessionMiddleware at every release in the window — with the session cookie. Results: 1.5.0 -> passes the freshness gate and fails later at the handler with BAD_REQUEST / FAILED_TO_UNLINK_LAST_ACCOUNT, which is proof the middleware let it through; 1.6.0 -> FORBIDDEN / SESSION_NOT_FRESH; 1.7.3 -> FORBIDDEN / SESSION_NOT_FRESH. The 1.7.3 run needs { accountId } in the body rather than 1.5.0's { providerId } — that route's body schema changed independently — so all three bodies were tried at every release and the table above reports the ones that reached the middleware.
THE NEGATIVE HALF, verified by absence the way LF10 was: session.freshAge is declared freshAge?: number in @better-auth/core/dist/types/init-options.d.mts at 1.7.3 and nothing else in the session options block mentions freshness; the strings freshAgeFrom, freshAgeBasis and freshFrom return zero matches across better-auth/dist and every @better-auth/*/dist at that release. The anchor is not selectable at any release, which is what makes freshAge: 0 plus your own middleware the honest answer for anyone who wants the old behaviour back.
The vendor's own note calls this an alignment rather than a change of behaviour — dist/api/routes/update-user.mjs was already createdAt-based at 1.5.0, so 1.6.0 made the two agree. That is true and it is not a reason to soften the fact: the middleware that gates the sensitive endpoints is the one that moved, and it moved in the direction that rejects requests it used to allow.
Reproduced against: Claude Fable 5.1 (S2) — better-auth/v7-c, 2026-09-06.
Source: better-auth 1.6.0 release notes — the breaking change, and the vendor's own migration line · 2026-04-06 · better-auth 1.5.0 published package — freshSessionMiddleware reads updatedAt · 2026-03-01 · better-auth 1.6.0 published package — the same middleware reads createdAt · 2026-04-06 · better-auth 1.7.3 published package — still createdAt, so the fact is about what is true now
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.
// Stale
// stale: baseURL believed to be string-only, so the host has to come from the environment
export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL!, // one value per deployment
trustedOrigins: ['https://acme.example.com', 'https://*.vercel.app'], // does NOT set the base URL
})
// Current
// since 1.5.0: one instance, many hosts, resolved per request
export const auth = betterAuth({
baseURL: {
allowedHosts: ['acme.example.com', '*.vercel.app', 'preview-*.example.com'],
fallback: 'https://acme.example.com', // used when the request host matches nothing
protocol: 'auto', // 'auto' reads x-forwarded-proto; 'https' | 'http' force it
},
})
Verified by execution against an installed better-auth@1.5.0, not from the release note.
betterAuth()constructs with the object form;isDynamicBaseURLConfig(cfg)is true for it and false for a string;resolveDynamicBaseURL(cfg, request, '/api/auth')returnshttps://acme.example.com/api/auth,https://my-branch.vercel.app/api/authandhttps://preview-7.example.com/api/authfor the three matching hosts, and returns the fallback — not the request host — forevil.attacker.com. The object form also type-checks undertsc --strict. The floor is the published type:@better-auth/core@1.4.22declaresbaseURL?: string | undefined,@better-auth/core@1.5.0declaresbaseURL?: BaseURLConfig | undefinedwhereBaseURLConfig = string | DynamicBaseURLConfig. Still present at 1.7.2. 1.6.0 adds amatchesHostPatternexport and rewrites thetrustedOriginsdocumentation around this; the option itself is unchanged between 1.5.0 and 1.6.0.
Reproduced against: Claude Opus 5 (S2) — better-auth/v3-a, 2026-09-03.
Source: @better-auth/core 1.5.0 published package — dist/types/init-options.d.mts · 2026-03-01 · @better-auth/core 1.5.0 published package — the allowedHosts field · 2026-03-01 · better-auth 1.4.22 — the last release before the change, where baseURL is string-only · better-auth v1.5.0 release notes · 2026-03-01
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.
// Stale
// stale: relies on a signature check that is off by default
export const auth = betterAuth({
plugins: [bearer()],
})
// Current
export const auth = betterAuth({
plugins: [bearer({ requireSignature: true })],
})
Verified at both ends: the 1.1.0 notes state the default changed, and better-auth 1.7.2's shipped types still document
requireSignature?: booleanwith@default false. This fact is about the default, not about whether the option exists — models tend to know the option and not the default.
Reproduced against: Claude Sonnet 5 (S2) — better-auth/v1, 2026-09-01.
Source: better-auth v1.1.0 release notes · 2024-12-20 · better-auth 1.7.2 published package — dist/plugins/bearer/index.d.mts · 2026-08-26
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.
// Stale
const { data } = await authClient.signIn.email({ email, password })
// believed necessary: a second round trip for the custom field
const { data: session } = await authClient.getSession()
const role = session.user.role
// Current
const { data } = await authClient.signIn.email({ email, password })
const role = data.user.role // present since 1.4.2 if declared in user.additionalFields
Bisected against the published artifacts, not taken from the release note. 1.4.1 (2025-11-25) builds the response
useras a seven-key object literal; 1.4.2, published the same day, replaces it withparseUserOutput(ctx.context.options, user.user), and every release through 1.7.2 keeps that. The seven-key literal had been in place since 1.1.0. Untested against any subject so far — 1.4.2 precedes the stated cutoff of all three current subjects, so it is admissible against each of them.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: better-auth v1.4.2 release notes · 2025-11-25 · better-auth 1.4.2 published package — the sign-in email handler · 2025-11-25 · better-auth 1.4.1 published package — the same handler one release earlier · 2025-11-25
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.
- It transforms the
identifiercolumn 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'sreset-password:<token>— sostoreIdentifiergenuinely covers those, including the password-reset rows no plugin option reaches. It is not the secret for code-shaped flows:emailOTPwritesidentifier: 'sign-in-otp-<email>'andvalue: '<otp>:<attempts>', andphoneNumberwritesidentifier: '<phone number>'andvalue: '<code>:<attempts>'. SettingstoreIdentifier: 'hashed'on those hashes the address and leaves the code in the clear. For a one-time code, the plugin's ownstoreOTPis the only option that touches it.
'hashed'is a bare, unkeyed SHA-256 (base64url, unpadded) of the value, everywhere it appears — the samedefaultKeyHasherin 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, usestoreOTP: 'encrypted'(keyed by the app secret) or pass a{ hash }that is an HMAC whose key lives outside the database. Nobody needs adatabaseHookstransform 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.
The code below needs better-auth 1.5.0 or later. Between 1.3.0 and 1.5.0 this correction does not apply — see the note.
// Stale
// right about the plugins, wrong about the root config:
export const auth = betterAuth({
plugins: [
magicLink({ storeToken: 'hashed' }), // correct, and available since 1.3.0
emailOTP({ storeOTP: 'hashed' }), // correct, and available since 1.3.0
],
// ...and then, for password-reset identifiers, a hand-rolled hook
// because "there is no table-wide switch" — which stopped being true at 1.5.0
})
// Current
// 1.3.0 and later — per plugin, on the column the code actually lives in
export const auth = betterAuth({
plugins: [
magicLink({ storeToken: 'hashed' }), // token IS the identifier; SHA-256 is fine here
emailOTP({ storeOTP: 'encrypted' }), // 6-digit code: keyed, not a bare digest
// or, to keep it one-way, an HMAC keyed outside the database:
// emailOTP({ storeOTP: { hash: async (otp) => hmac(process.env.OTP_PEPPER, otp) } }),
],
})
// 1.5.0 and later — the whole verification table's IDENTIFIER column, in one place.
// Covers magic-link tokens and password-reset tokens (the secret is the identifier).
// Does NOT cover emailOTP / phoneNumber codes: those live in the `value` column.
export const auth = betterAuth({
verification: {
storeIdentifier: 'hashed',
// or: { hash: async (id) => myHash(id) }
// or: { default: 'hashed', overrides: { 'email-otp': 'plain' } }
},
})
CORRECTED 2026-09-03, the same session this fact was written, by the battery that was built to probe it (JOURNAL/044). As first written this fact claimed that hashing verification secrets at rest was new in 1.5.0 and that the pre-1.5.0 answer was a hand-rolled hook or adapter. That was wrong, and three of the four
better-auth/v3draws said so by answering correctly:magicLink({ storeToken })andemailOTP({ storeOTP })are present in the publishedbetter-auth@1.3.0type declarations (2025-07-19) and absent from 1.2.9 and 1.2.12, so they predate every current subject's cutoff. One draw reproduced the magic-link object form{ type: 'custom-hasher', hash }verbatim from memory. What 1.5.0 actually adds is scope, and that half was verified by execution against an installed 1.5.0 on a migrated sqlite database: with noverificationoption,internalAdapter.createVerificationValue({ identifier: 'magic-link-token-abcdef', ... })leaves the identifier in the row verbatim; withstoreIdentifier: 'hashed'the row holdsxj0EjmvN-maFOsKICHBPdQPo_JzYzonW9_ZKCCWOZgU, andfindVerificationValue('tok-round-trip')— passing the RAW identifier — still resolves. Both the plain string and the{ default, overrides }form type-check undertsc --strict.@better-auth/core@1.4.22has nostoreIdentifieranywhere in its options; 1.5.0 introducesStoreIdentifierOption = 'plain' | 'hashed' | { hash }, unchanged at 1.7.2. The option is verification-only: there is no session-token equivalent at any release —session: { storeIdentifier: 'hashed' }is a TS2353 error at 1.5.0 through 1.7.2. EXECUTION EVIDENCE FOR THE 1.3.0 HALF, added 2026-09-03 whenbetter-auth/v4was pre-registered against it: the half JOURNAL/044 established from published type declarations has now been run. Against an installedbetter-auth@1.3.0on an in-memory sqlite database: with no option,emailOTPdelivers797478to the user and theverificationrow holds797478:0; withstoreOTP: 'hashed'the user gets802759and the row holdscnKCtH_41s8ZE_LoK2szKnNKSkQ39k845SnxCBBlaz4:0; withstoreOTP: { hash }the row holds the custom digest; and signing in with the RAW code succeeds in all three.magicLinkstores the delivered token verbatim as the row's identifier by default; understoreToken: 'hashed'the user receivesBjgkRDFhWYvzaJKlpIWtYBqUWLpwMjgQwhile the row identifier isd7yXTliYOZv5Yz7qOuirVa2vH3uHIFLFi-F9dHuYxPc, andmagicLinkVerifycalled with the RAW token still resolves the user. The surface is wider than first written:storeOTP/storeTokenappear nowhere inbetter-auth@1.2.12'sdistand in 1.3.0 they are on FOUR plugins —emailOTP,magicLink,twoFactor.otpOptionsandoneTimeToken. All five one-time-code plugins already exist at 1.2.12, so what 1.3.0 adds is the option, not the plugin.phoneNumberis the exception at every release:phoneNumber({ sendOTP, storeOTP: 'hashed' })is a TS2353 error undertsc --strictat 1.3.0, 1.5.0 and 1.7.2, in the same file where the other four type-check clean. That absence is the same-scheme sibling control for battery v4 and is not itself a correction.
SCOPE AND CRYPTOGRAPHY CORRECTED 2026-09-03 (JOURNAL/047), from the source of an installed package rather than from its type declarations. Two claims in this fact as previously written were too broad, and the second is the one that mattered.
(1) WHAT 'hashed' COMPUTES. A bare, unsalted, unkeyed SHA-256, base64url-encoded without padding. It is one four-line defaultKeyHasher, duplicated verbatim in dist/db/verification-token-storage.mjs (the root switch) and dist/plugins/email-otp/utils.mjs (the plugin options). Confirmed without running better-auth at all: every digest this note already recorded from execution reproduces from node:crypto alone — sha256('magic-link-token-abcdef') is xj0EjmvN-maFOsKICHBPdQPo_JzYzonW9_ZKCCWOZgU, sha256('802759') is cnKCtH_41s8ZE_LoK2szKnNKSkQ39k845SnxCBBlaz4, sha256('BjgkRDFhWYvzaJKlpIWtYBqUWLpwMjgQ') is d7yXTliYOZv5Yz7qOuirVa2vH3uHIFLFi-F9dHuYxPc, all three byte-identical to the stored rows. So for a six-digit code the stored digest is a 10^6 offline space and buys ~nothing against an attacker holding the table. THREE better-auth/v5 DRAWS ARGUED EXACTLY THIS, unprompted, while answering a different question (JOURNAL/046, BACKLOG 2i-iii-a). They were right, and the fact they were arguing against was ours.
(2) A KEYED MODE EXISTS AND THIS FACT DID NOT NAME IT. storeOTP: 'encrypted' is in the union on BOTH OTP options — emailOTP and twoFactor.otpOptions — at 1.3.0, 1.5.0 and 1.7.2, and it is not in the union on either token option (magicLink.storeToken, oneTimeToken.storeToken) at any of the three, nor on the root StoreIdentifierOption. It routes through symmetricEncrypt: XChaCha20-Poly1305 with a managed nonce under SHA-256(secret). Executed at 1.7.2 on the memory adapter — delivered OTP 296902, row value 823ac13b...5679d367:0, the code absent verbatim, and signInEmailOTP with the RAW code still resolves the user. The split is coherent design rather than an oversight: the keyed mode is on the two options that carry short numeric codes and absent from the two that carry high-entropy random tokens.
(3) THE ROOT SWITCH IS A COLUMN, NOT A ROW — the correction that propagates to LF10. createVerificationValue applies processIdentifier to data.identifier and to nothing else. Executed at 1.7.2 with verification: { storeIdentifier: 'hashed' } and no plugin options set: the phone row holds identifier eo4brUJBb5o2Tie1SU8kmQkN7zk3E0Tf45kV88XdGnU = sha256('+15550001111') with value 668217:0, and the email-OTP row holds identifier 4TrfjL5FzYyFAZKa-CtgMLAlxFbsHqb1XKhzAaPjiDc = sha256('sign-in-otp-a@example.com') with value 090934:0. Both delivered codes sit in the table in plain text with the switch ON. It covers reset-password:<token> and magic-link rows because there the token IS the identifier; it does not cover a one-time code, ever.
WHY THIS SURVIVED THE FIRST PASS, worth recording as method: the 1.5.0 half was verified by calling internalAdapter.createVerificationValue({ identifier: 'magic-link-token-abcdef' }) directly, which exercises only the column the switch transforms. A direct adapter call cannot show you a column the plugin fills. Verifying a scope claim requires driving the plugin's own endpoint.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: better-auth 1.3.0 published package — the magic-link storeToken option · 2025-07-19 · better-auth 1.3.0 published package — the email-OTP storeOTP option · 2025-07-19 · @better-auth/core 1.5.0 published package — the verification.storeIdentifier option · 2026-03-01 · @better-auth/core 1.5.0 published package — StoreIdentifierOption · 2026-03-01 · better-auth 1.5.0 published package — executed against a migrated sqlite database · 2026-03-01 · better-auth 1.3.0 published package — executed against an in-memory sqlite database · 2025-07-19 · better-auth 1.7.2 published package — defaultKeyHasher in dist/db/verification-token-storage.mjs · better-auth 1.7.2 published package — the keyed 'encrypted' mode on the OTP options · better-auth 1.7.2 published package — createVerificationValue transforms the identifier column only
signIn.email / auth.api.signInEmail response
Behaviour changed in better-auth 1.1.0 (2024-12-20)
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.
// Stale
const { data } = await authClient.signIn.email({ email, password })
console.log(data.session.expiresAt) // undefined -> TypeError
// Current
const { data } = await authClient.signIn.email({ email, password })
console.log(data.user.email) // fine
console.log(data.token) // the session token
// the session itself comes from a separate call
const { data: s } = await authClient.getSession()
ARCHAEOLOGY, 2026-09-02: the 1.1.0 release note quoted below is wrong about 1.1.0's own published artifact, in two ways, and the Index has now bisected the tarballs rather than trusting it. (1) The user object never left. 1.0.0 returned
{ user, session, redirect, url }with the whole user row; 1.1.0 returned{ user, redirect, url }whereuseris a fixed seven-field literal — id, email, name, image, emailVerified, createdAt, updatedAt. Thesessionobject is what 1.1.0 removed, which is the part this fact asserts. (2) There was notokenfield in 1.1.0 at all:tokenfirst appears in 1.1.4 (2024-12-27), a week later. So the earlier note's open question — when the user object came back — is void; there was nothing to come back. What did change later is which user fields are returned: see LF7.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: better-auth v1.1.0 release notes · 2024-12-20 · better-auth 1.7.2 published package — dist/api/routes/sign-in.mjs · 2026-08-26
Deprecated, or a better API now exists
Works today. It is the older idiom, and some of it is scheduled for removal.
twoFactorClient({ twoFactorPage })
Added in better-auth 1.6.0 (2026-04-06)
Since 1.6.0 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.6.0 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.0 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.0, false from 1.6.0. 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 — the history runs the other way: the callback came first and the string was added on top of it at 1.6.0.
// Stale
// Correct through 1.5.0, and still correct today — but no longer the only way,
// and the denial that a string option exists is what makes it a stale prior.
export const authClient = createAuthClient({
plugins: [
twoFactorClient({
onTwoFactorRedirect() {
window.location.href = '/auth/two-factor'
},
}),
],
})
// Current
// 1.6.0 and later — hand it the path once.
export const authClient = createAuthClient({
plugins: [twoFactorClient({ twoFactorPage: '/auth/two-factor' })],
})
// The callback is still there, and since 1.6.0 it is told which factors the
// user actually has — use it when you want to route without a full reload:
export const authClient2 = createAuthClient({
plugins: [
twoFactorClient({
onTwoFactorRedirect({ twoFactorMethods }) {
router.push(twoFactorMethods?.includes('totp') ? '/auth/totp' : '/auth/otp')
},
}),
],
})
VERIFIED AGAINST THE SHIPPED ARTIFACT, 2026-09-06, in
dist/plugins/two-factor/client.d.mtsof three installed releases. At 1.5.0 the options object declares one member,onTwoFactorRedirect?: () => void | Promise<void>. At 1.6.0 and 1.7.3 it declares two:twoFactorPage?: stringandonTwoFactorRedirect?: (context: { twoFactorMethods?: string[] }) => void | Promise<void>.dist/plugins/two-factor/client.mjsat 1.7.3 carries the navigation.
SCORED S3 DELIBERATELY AND IN ADVANCE (prompts/better-auth.md § v7): the pre-1.6.0 answer still compiles and still behaves correctly, so a reader who takes it ships working code with an unnecessary callback. The severity ceiling was fixed before the draws were read.
A DERIVABILITY CAVEAT belongs on this fact and is recorded rather than buried. The name is reachable from the question: the derivability control (Claude Haiku 4.5, cutoff February 2025, far below every release involved) answered "yes" and produced twoFactorPath — the right shape, one character off the right name — while stating it was a guess. Per the standing rule, a derivable outcome discounts a pass and does not excuse a failure; the denials charged against this fact are unaffected, but nobody should read a pass on this surface as recall.
Reproduced against: Claude Fable 5.1 (S3), Claude Opus 5 (S3) — better-auth/v7-a, 2026-09-06; better-auth/v7-c, 2026-09-06.
Source: better-auth 1.6.0 published package — twoFactorClient gains twoFactorPage · 2026-04-06 · better-auth 1.5.0 published package — the callback is the only option · 2026-03-01 · better-auth 1.7.3 published package — the navigation the string option performs · better-auth 1.6.0 release notes — the feature line · 2026-04-06
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.
import { betterAuth } from 'better-auth'
import { deviceAuthorization } from 'better-auth/plugins'
export const auth = betterAuth({
plugins: [deviceAuthorization()],
})
// client
import { createAuthClient } from 'better-auth/client'
import { deviceAuthorizationClient } from 'better-auth/client/plugins'
export const authClient = createAuthClient({
plugins: [deviceAuthorizationClient()],
})
Introduced in a PATCH release. 1.3.7 (2025-08-17) has no
deviceAuthorizationexport anywhere indist/; 1.3.8 (2025-09-04) exports it fromdist/plugins/. That is worth stating on its own: better-auth ships whole plugins in patches, socadence_minors_12mo_to_cutoff— which counts minors and majors — understates how much of this library's surface is new. Distinct fromoauthDeviceAuthorization(), added in 1.7.0 (2026-08-18) for the OAuth-provider device grant; this fact makes no claim about that one.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: better-auth v1.3.8 release notes · 2025-09-04 · better-auth 1.3.8 published package — dist/plugins/index.d.mts · 2025-09-04
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.
import { betterAuth } from 'better-auth'
import { lastLoginMethod } from 'better-auth/plugins'
export const auth = betterAuth({
plugins: [lastLoginMethod()],
})
Also a patch-release addition — absent from 1.3.7's
dist/, present in 1.3.8's. Later releases added options this fact does not cover:beforeStoreCookiefor GDPR compliance in 1.6.24 (2026-07-22), and a cross-subdomain cookie-clearing fix in 1.6.19 (2026-06-16).
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: better-auth v1.3.8 release notes · 2025-09-04 · better-auth 1.3.8 published package — dist/plugins/index.d.mts · 2025-09-04
SSO plugin — SAML 2.0
Added in better-auth 1.3.0 (2025-07-19)
The SSO plugin speaks SAML 2.0 as well as OIDC since 1.3.0. A customer whose identity provider does not support OIDC can still be onboarded through the same plugin; "better-auth only supports OIDC for enterprise SSO" has been wrong since 2025-07-19.
The stale belief: That the SSO plugin is OIDC-only and a SAML customer needs a third-party bridge or a different auth library.
Scored S3 rather than S2: a model that says "OIDC only" sends the reader to look for a workaround, but does not produce running code that misbehaves. The introducing release is cited from the vendor's own notes. This fact deliberately does not state the shape of the SAML config object, which was not verified against the shipped package — the SSO plugin has since moved to a separate
@better-auth/ssopackage and its current option names were not checked.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: better-auth v1.3.0 release notes · 2025-07-19
Not corrections — recorded for honesty
Claims seen in a run but not yet verified against a primary source. Never treated as findings:
- Both Fable draws describe the missing phone-number storage option as a known upstream complaint — "It's a known asymmetry people complained about on GitHub." The asymmetry is real and was verified by type-checking, but no issue or discussion was located this session, so the claim that it was publicly complained about is unverified. (open since 2026-09-03)
- The subject named "the early 1.3.x patches (roughly 1.3.1+)" as the first thing it cannot describe.
knowledge_gap_starts_at_versionis recorded as 1.4.0, the next release with substantial describable content, rather than 1.3.1: no subject can describe individual patches for any library in this dataset, so patch-level non-recall is release granularity rather than an attribution boundary. Recording 1.3.1 would have claimed a one-day boundary window, which the evidence does not support. (open since 2026-09-01) - This draw and
v1r-bagree with each other and withbetter-auth/v1on the boundary, while the concurrent prisma replicates of the same model disagreed by 204 days. Boundary stability therefore varies by library for a fixed model — but two replicated libraries is not enough to say what property of a library predicts it, and the pre-registered guess (unambiguous version history replicates better) was falsified in the opposite direction. (open since 2026-09-01) - Both better-auth replicates denied that database-less sessions exist, and
better-auth/v1charged no findings at all against this subject. Whetherv1was under-scored, or whether the denial is an artefact of how task 11 is worded, is unresolved and needs a battery that probes the 1.4.0 surface directly rather than a replicate. (open since 2026-09-01) - Three measurements of this model's better-auth boundary —
v1,v1r-aand this run — agree, while two measurements of its prisma boundary taken the same day disagree by 204 days. What property of a library makes its boundary reproducible is now an open and answerable question; the pre-registered guess was falsified in the opposite direction. (open since 2026-09-01) - Both replicates independently denied that database-less sessions exist. Whether
better-auth/v1's zero-finding result was under-scored needs a battery aimed at the 1.4.0 surface, not another replicate. (open since 2026-09-01) - Is
deviceAuthorization()a fair probe at all, given thatdeviceAuthorizationis close to the obvious name for an RFC 8628 implementation? The below-floor control (Claude Sonnet 5, whose boundary is four minors lower) also produced the name, while volunteering that it could not rule out reconstructing it from generic RFC knowledge. (open since 2026-09-02) - Does the
baseURLdenial survive a probe that does not mention Vercel or preview deployments? Every task-1 framing here routed through a hosting platform, and a subject may be reasoning "that is a deployment concern, not a library concern" rather than recalling thatbaseURLis string-only. (open since 2026-09-03) - Both Opus 5 draws claim that
session.cookieCacheand thecustomSessionplugin do not compose cleanly — that a cached read can return a response missing the custom field or a stale one, and that the behaviour has moved across versions. Neither draw would state which versions. Not verified against any installed release this session. (open since 2026-09-03) - The draw makes a security argument this battery did not verify and that is worth checking independently: that hashing a six-digit OTP is near-worthless against the stated threat actor, because an attacker holding the row can brute-force a 10^6 space offline in milliseconds, so the mitigation only covers incidental exposure. Both
v5-bandv5-dmade the same argument, andv5-ddrew the further conclusion that only a keyed transform (theencryptedmode, or an HMAC) closes the row. If that is right, the Index's own LF9 recommendation ofstoreIdentifier: 'hashed'is under-specified for short numeric codes and should say so. (open since 2026-09-03) - The subject named "~1.3.8 onward" as the first thing it cannot describe.
knowledge_gap_starts_at_versionis recorded as 1.4.0 for the same reason as the Fable 5 run: patch-level non-recall is release granularity, not an attribution boundary. (open since 2026-09-01) - SELF-TEST. This run is marked
self_test: true-- the subject model is the same model operating the studio. It is the fourth such run in the dataset and the caveat is unchanged: a zero-finding result from a self-test is the one result most likely to be flattered by the operator, so the scoring here was made stricter rather than looser, and the one place Opus 5 missed a capability is recorded aschargeable_miss: truein the non-findings rather than dropped. (open since 2026-09-01) - The 1.3.0 verification-hashing options (
storeToken,storeOTP) are admissible against all three current subjects, and the four draws split on them: two named them correctly, one hedged them to 60%, and this one denied their existence at any version. Is that a real difference in knowledge, or an artefact of this battery's framing, which asked about theverificationtable rather than about the plugins? (open since 2026-09-03) - The subject declined to name a first-undescribable version at all, saying it has no reliable version sequence past 1.0.0.
knowledge_gap_starts_at_versionis recorded as 1.1.0 -- the next release, whose contents it did not describe -- rather than quoted from the subject. Flagged so the bracket is not read as tighter than the subject's own answer supports. (open since 2026-09-01) - The subject dated 1.0.0 to "roughly September 2024"; it actually shipped 2024-11-23. Version identification was right and date recall was two months early. Whether attribution boundaries are systematically dated early is worth checking across the existing six libraries. (open since 2026-09-01)
Findings, code and citations: data/better-auth/ — one JSON file and one write-up per model, each finding carrying the release that broke the belief, its publication date and a verbatim quote from the primary source. Corrections: data/better-auth/facts.json. This file is generated by tools/build-corrections.mjs; if the prose and the data ever disagree, that is a bug in the generator, not a stale pack.