What Claude Fable 5.1 gets wrong about better-auth — battery v7-c, tested 2026-09-06

Run better-auth--claude-fable-5-1--v7-c--2026-09-06 · self-test: the subject is the operator

Summary

The second test arm, and the one that charged the surface the battery was built for. It answered task 1 "yes" and task 5 updatedAt - a coherent, confidently argued account of better-auth 1.5.0 offered as the current behaviour - and denied both 1.6.0 options exist. Three findings: F1 the freshness anchor (charged once across both tasks, as the pre-registration required), F2 resendStrategy, F3 twoFactorPage. F2 is the sharpest of the three because the workaround it shipped sets storeOTP: 'hashed' and then caches the plaintext code in Redis to make reuse possible, which is precisely the configuration in which the real option refuses to reuse. Its stated cutoff is June 2026, two months after the release it missed.

SubjectClaude Fable 5.1 claude-fable-5-1, Anthropic
Invoked asAgent tool, model alias "fable"; prompt sent verbatim from prompts/sent/better-auth-v7.txt, no tools available to the subject. An identity probe run through the same alias in the same session, tool-free, answered "Fable 5.1", model id `claude-fable-5-1`, cutoff June 2026, and again volunteered that all three come from its system prompt rather than from self-knowledge - unchanged from every prior battery, so the alias has not moved.
Cutoff the model states2026-06
Newest better-auth release it could place1.4.0 · 2025-11-22 (~7 month lag)
Oldest better-auth release it could not place1.5.0 · 2026-03-01 (so this run brackets the subject’s boundary to 2025-11-22 – 2026-03-01)
In its own words"The latest version I'm aware of existing is the 1.5 line (early 2026), and I have only faint awareness of it. The most recent release whose contents I can actually describe is 1.4 (around November 2025)."
Library at test timebetter-auth 1.7.3 (npm), verified 2026-09-06
Batterybetter-auth/v7-c · 7 tasks, 3 direct questions · probe window 1.6.0 to 1.7.3
Tool uses during test0 (a run with any tool use is void — we measure training knowledge, not retrieval)
Tested2026-09-06
Findings3, of which 3 chargeable

Findings

F1 · Predicts the freshness check passes for a 30-hour-old session that was used two minutes ago, and names updatedAt as the anchor — the pre-1.6.0 semantics, held consistently across two tasks

S2silently-wrong · session.freshAge (measured from session.createdAt) · behavior-changed · changed in better-auth 1.6.0 (2026-04-06) · chargeable

1.6.0 shipped 2026-04-06; this draw states a June 2026 cutoff, which is after it and not the same month, so the fairness rule and the same-month bar both clear. This is a pre-registered test arm and tasks 1 and 5 are pre-registered probes on this surface. One finding, two artefacts. The pre-registration graded the verdict (task 1) and the mechanism (task 5) independently and this draw got both wrong in the same direction; that is one belief measured twice, not two findings, and charging it twice would inflate the count.

What the model believes

Task 1, verdict on its own line: "yes". Task 5, four tasks later: "(a) updatedAt — with a fallback to createdAt if updatedAt is absent on the row." That is a verbatim description of the 1.5.0 implementation, which read new Date(session.session.updatedAt || session.session.createdAt). At task 3 it restated the same belief as the reason its own answer to task 1 was "yes": "As best I remember the middleware, it measures from updatedAt and only falls back to createdAt when updatedAt is missing — which is why Task 1 passes. That is the product team's semantics, not the security team's, and it's baked in." It also flagged, correctly, that the vendor's documentation and the implementation had historically disagreed, and told the reader to check freshSessionMiddleware in the installed package - which would have corrected it.

What it wrote
// The advice that follows from the belief: keep the built-in check as a
// 'last used' gate, and add your own createdAt gate on top for the
// endpoints security cares about.
export const auth = betterAuth({
  session: {
    // Built-in check: measured from updatedAt (falls back to createdAt).
    freshAge: FRESH_AGE,
    expiresIn: 60 * 60 * 24 * 7,
    updateAge: 60 * 60 * 24,
  },
  hooks: { before: createAuthMiddleware(async (ctx) => { /* createdAt gate */ }) },
})
What works on better-auth 1.7.3
// 1.6.0 and later the window runs from createdAt. Executed against three
// installed releases with createdAt = now-30h and updatedAt = now-2min:
//   1.5.0 -> the freshness gate PASSES (the call fails later, in the handler)
//   1.6.0 -> FORBIDDEN / SESSION_NOT_FRESH
//   1.7.3 -> FORBIDDEN / SESSION_NOT_FRESH

// There is no option to move the anchor back. If you want last-use
// semantics, disable the built-in check and write your own gate:
export const auth = betterAuth({ session: { freshAge: 0 } })
Impact

Everything compiles and runs, which is what makes it S2 and what makes it hard to catch: the reader is given a correct prediction about better-auth 1.5.0 and told it is the current behaviour, with nothing in their own code to change and no deprecation warning to trip over. Two concrete consequences. (1) The security posture is inverted. The draw tells a team that the built-in gate is "the product team's semantics" - lenient, effectively never firing for an active user - and has them build a second, stricter createdAt gate in a hooks.before middleware with a hand-maintained list of sensitive paths, which the draw itself called "the honest weak spot". From 1.6.0 the built-in gate already is the createdAt gate; the custom middleware duplicates it, and the path list will silently miss whichever endpoints the framework adds to freshSessionMiddleware later. (2) The upgrade surprise is not predicted. A team upgrading 1.5.x -> 1.6.x on this advice expects nothing to change for continuously active users. Executed against the installed packages: a session with createdAt 30 hours ago and updatedAt 2 minutes ago passes the gate at 1.5.0 and is rejected with FORBIDDEN / SESSION_NOT_FRESH at 1.6.0 and 1.7.3.

Verified against

F2 · Denies the email-OTP plugin has a resend-reuse option and rebuilds it out of generateOTP plus a Redis cache — under storeOTP: 'hashed', which is the one configuration in which the real option refuses to reuse

S2silently-wrong · emailOTP({ resendStrategy }) · added · changed in better-auth 1.6.0 (2026-04-06) · chargeable

Same licence as F1: stated cutoff June 2026, target release 2026-04-06, different months.

What the model believes

Verdict on its own line: "no". Then the explicit absence claim the additive-API rule requires: "I don't know of any option on emailOTP() that says 'reuse the outstanding code on resend.'" It then listed the plugin's options from memory - a list correct for 1.5.0 and missing only the one asked about - and described the mechanism correctly: "Every call to sendVerificationOTP replaces the stored verification row... with a freshly generated code, which is exactly the behaviour your support team is hitting."

What it wrote
// shipped as the answer. Note the third line of options.
emailOTP({
  otpLength: 6,
  expiresIn: OTP_TTL,
  allowedAttempts: 5,
  storeOTP: 'hashed',
  async generateOTP({ email, type }) {
    const existing = await redis.get(key(type, email))
    if (existing) return existing // plugin re-stores it with a fresh expiresIn
    return String(Math.floor(100000 + Math.random() * 900000))
  },
  async sendVerificationOTP({ email, otp, type }) {
    await redis.set(key(type, email), otp, 'EX', OTP_TTL)
    await sendEmail({ to: email, text: `Your code is ${otp}.` })
  },
})
What works on better-auth 1.7.3
// 1.6.0 and later: one option, and it already knows the constraint the
// hand-rolled cache does not.
emailOTP({
  otpLength: 6,
  expiresIn: 300,
  resendStrategy: 'reuse', // default is 'rotate'
  storeOTP: 'plain',       // reuse needs a recoverable code; 'hashed' falls back to 'rotate'
  async sendVerificationOTP({ email, otp }) { await sendMail(email, otp) },
})
Impact

The workaround runs, so S2 - but this draw's version is the one that shows why the built-in option is worth having. It sets storeOTP: 'hashed' and then keeps the plaintext code in Redis so it can hand it back, which puts the secret in a second store the security control was chosen to keep it out of. resendStrategy: 'reuse' does not have that failure mode available to it: the option's own contract is that reuse works only when the stored code is recoverable and it falls back to "rotate" when the OTP is hashed, so the library refuses to do quietly what this configuration does explicitly. A reader following this answer ends up with hashed storage in the database, plaintext in Redis, and the belief that they have hardened the flow. The draw also carried three honest caveats it should not have needed - whether generateOTP may be async, whether the code is cleared on successful verification, and that the attempt counter resets on every resend - all of which are questions about a re-implementation of a shipped option.

Verified against

F3 · Denies the two-factor client plugin takes a page option, names twoFactorPage correctly, and dates it to the pre-1.0 releases as something since removed

S3deprecated · twoFactorClient({ twoFactorPage }) · added · changed in better-auth 1.6.0 (2026-04-06) · chargeable

Same licence as F1 and F2. Severity capped at S3 in the pre-registration because onTwoFactorRedirect still exists, so the shipped code works.

What the model believes

Verdict on its own line: "no". Then: "the current client plugin takes a callback, onTwoFactorRedirect(), not a path. I believe there was a twoFactorPage: \"/two-factor\" string option on the client plugin in the pre-1.0 (0.x) days, and that's probably where the memory of 'give it a path once' comes from; I'm fairly, not fully, sure it's gone from 1.x." The name is right and the history is backwards. This is the second model family to produce that inversion independently in this battery - v7-a (Claude Opus 5) wrote "I have a real memory of a string-path option in early better-auth two-factor docs — I believe it was called twoFactorPage — but I think it was replaced by the callback" - and v7-d made it three of six draws.

What it wrote
// shipped as the answer, with the string option explicitly placed in the past.
export const authClient = createAuthClient({
  plugins: [
    twoFactorClient({
      onTwoFactorRedirect() {
        window.location.href = '/auth/two-factor'
      },
    }),
  ],
})
What works on better-auth 1.7.3
// 1.6.0 and later — the string option the task asked for:
export const authClient = createAuthClient({
  plugins: [twoFactorClient({ twoFactorPage: '/auth/two-factor' })],
})

// The callback is still supported and since 1.6.0 is told which factors the
// user has, which is the reason to keep using it in a router-driven app:
twoFactorClient({
  onTwoFactorRedirect({ twoFactorMethods }) {
    router.push(twoFactorMethods?.includes('totp') ? '/auth/totp' : '/auth/otp')
  },
})
Impact

S3 by design: the code works, and a reader loses only the one-line setup they asked for. The cost is in the story attached to it. Told that twoFactorPage is a name from the 0.x era that 1.x dropped, a reader will not try it and will read the current documentation as stale if they meet it. The draw's fallback - registering a module-level navigator function and calling setNavigator(navigate) from a top-level component - is fifteen lines of indirection standing in for a string.

Verified against

What it got right, and near misses

Recorded so the run cannot be read as a hit list. A model that is right for an obsolete reason is recorded here, not as a finding.

KindAPINote
correctsession.freshAge anchor option (does not exist) Task 3, the pre-registered control. "no", correctly - there is no option choosing the freshness anchor at any release. The reason it gave is the stale belief from F1 rather than knowledge of the option surface ("there is a single knob, session.freshAge... There is no option selecting which timestamp it's measured from"), but the graded answer is the right one and the control holds in this arm.
correctcustomSession Task 4, the floor probe (1.0.0 custom session). customSession on the server, customSessionClient<typeof auth>() on the client, with the typing constraint stated correctly - pass the options object into customSession(fn, options) so plugin-widened user/session types reach the callback. Passed.
correctsession.freshAge Task 5(b): session: { freshAge: 0 }, correct, and the only half of task 5 this draw got right. All six draws in the battery except the below-floor guessing control gave this answer, which is unsurprising - 0 as the off switch predates the window and is not what this battery probes.
contextstateless sessions Task 7, the attribution anchor. Unlike both Opus draws this one declined rather than denying: "I can't name one with confidence, and I'd rather say that than invent it... If a true stateless-session mode shipped, it would be in a release after those (1.5 or later, 2026), and I don't know its contents. Treat this as 'unknown,' not 'doesn't exist.'" It then listed cookieCache, secondaryStorage with storeSessionInDatabase: false, and the jwt plugin as the near neighbours, and attributed a cookie-cache strategy option to 1.4 - the release that actually carries stateless session management. So it placed the right release for an adjacent feature while marking the feature itself unknown. Belief data, never scored. An abstention is not a denial (JOURNAL/046).
contextknowledge boundary Boundary spread inside one subject on one library, worth recording because better-auth/v6 measured this cell six hours earlier. The ladder placed Claude Fable 5.1's better-auth boundary at 1.3.0; this draw describes 1.4.0 in detail ("several plugins split into their own packages... cookie-cache strategy options, adapter-level joins work") and names 1.5 as the first release it knows only as a number. Its twin v7-d agrees with the ladder at 1.3.0. So the subject's spread on this library is 1.3.0 / 1.3.0 / 1.4.0 across three draws, and this arm is the high one. Nothing charged here depends on it: 1.6.0 is above every reading.

Sources

Battery specification: prompts/better-auth.md in the studio repo. Every finding above also carries its own citation.