What Claude Opus 5 gets wrong about better-auth — battery v7-a, tested 2026-09-06

Run better-auth--claude-opus-5--v7-a--2026-09-06 · self-test: the subject is the operator

Summary

The test arm, and it split the battery cleanly in two: right about the behaviour, wrong about both names. It answered task 1 correctly ("no" - the 1.6.0 freshness semantics), gave the right mechanism at task 5 (createdAt, freshAge: 0), and held the control at task 3 - then denied both 1.6.0 options exist, charging F1 (resendStrategy) and F2 (twoFactorPage). F2 carries the battery's strangest artefact: it named twoFactorPage correctly, from what it called "a real memory", and placed it in the library's past as a name that had been removed. Its stated cutoff is May 2026, a month after the target release, and its own summary of the gap is the honest one: "there is roughly eighteen months of better-auth development I cannot see."

SubjectClaude Opus 5 claude-opus-5, Anthropic
Invoked asAgent tool, model alias "opus"; prompt sent verbatim from prompts/sent/better-auth-v7.txt, no tools available to the subject
Cutoff the model states2026-05
Newest better-auth release it could place1.2.0 · 2025-03-01 (~26 month lag)
Oldest better-auth release it could not place1.3.0 · 2025-07-19 (so this run brackets the subject’s boundary to 2025-03-01 – 2025-07-19)
In its own words"Latest I know of by number: I have some sense of the 1.3.x line existing, and I would not be surprised by 1.4.x... Most recent release whose contents I can actually describe: 1.2, which I place around February-March 2025."
Library at test timebetter-auth 1.7.3 (npm), verified 2026-09-06
Batterybetter-auth/v7-a · 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
Findings2, of which 2 chargeable

Findings

F1 · Denies the email-OTP plugin has a resend-reuse option and ships a Redis cache in front of generateOTP instead of resendStrategy: 'reuse'

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

1.6.0 shipped 2026-04-06; this draw states a May 2026 cutoff, which is after it and not in the same month, so the fairness rule and the same-month bar (JOURNAL/060) both clear. This is the pre-registered test arm and task 2 is a pre-registered probe.

What the model believes

Verdict, on its own line: "No". Then, opening the code: "There is no reuseOTP / allowResend-style flag in the email-OTP plugin as far as I know" - a claim about the option's absence, not a silent workaround, which is what the additive-API rule requires before a finding may be drawn. It then enumerated the plugin's options from memory ("sendVerificationOTP, otpLength, expiresIn, allowedAttempts, sendVerificationOnSignUp, disableSignUp, generateOTP, overrideDefaultEmailVerification") - a list that is correct for 1.5.0 and is missing exactly the option the task asked about. It correctly diagnosed the mechanism ("A resend calls the same send endpoint, which generates a fresh code and overwrites the stored verification value - which is exactly the bug your support team is seeing") and then built the workaround the library no longer needs.

What it wrote
// shipped as the answer. Correct through 1.5.0; unnecessary from 1.6.0.
const live = new Map<string, { otp: string; expiresAt: number }>();
const key = (email: string, type: string) => `${type}:${email.toLowerCase()}`;

emailOTP({
  otpLength: 6,
  expiresIn: OTP_TTL_SECONDS,
  allowedAttempts: 3,
  // Reuse a still-valid code instead of minting a new one.
  generateOTP: ({ email, type }) => {
    const k = key(email, type);
    const existing = live.get(k);
    if (existing && existing.expiresAt > Date.now()) return existing.otp;
    const otp = String(Math.floor(Math.random() * 1_000_000)).padStart(6, '0');
    live.set(k, { otp, expiresAt: Date.now() + OTP_TTL_SECONDS * 1000 });
    return otp;
  },
  sendVerificationOTP: async ({ email, otp, type }) => { /* ... */ },
})
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 this is S2 rather than S1 - and it costs more than the lines it takes. The draw's own hedges show what the missing option would have settled for it: it was unsure whether generateOTP may return a promise ("I am not certain the plugin awaits a promise returned from generateOTP... if it's sync-only, you need a synchronous cache (a warm in-process LRU with sticky routing) or you drop this approach") and unsure of the verification row's identifier format, and it proposed sticky routing as the fallback for a multi-instance deployment. All of that is design work spent reconstructing resendStrategy: 'reuse'. It also misses the constraint the real option encodes: reuse is only possible when the stored code is recoverable, and the plugin falls back to rotating when storeOTP is 'hashed'. A reader who takes this answer, later turns on storeOTP: 'hashed' for the security review, and keeps the cache will send a code from the cache that no longer matches the row the plugin will verify against - the exact support ticket the task was about, reintroduced by the workaround.

Verified against

F2 · Denies the two-factor client plugin takes a page option — while naming twoFactorPage correctly and asserting it was removed rather than added

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

Same licence as F1. Severity was capped at S3 in the pre-registration, before any draw was read, because onTwoFactorRedirect still exists and the answer therefore ships working code.

What the model believes

Verdict, on its own line: "No". Then: "The current client plugin takes a callback, onTwoFactorRedirect, not a path string. There is no twoFactorPage: \"/auth/two-factor\" option on the release I know." And then, in the same breath, the sharpest sentence in the battery: "(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 I would not ship against it.)" The name is exactly right. The history is exactly backwards: the callback is the older of the two and twoFactorPage was added on top of it at 1.6.0.

What it wrote
// shipped as the answer, with the string option explicitly ruled out.
export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL,
  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

The shipped code compiles and works, which is why the severity was capped at S3 in advance. What the reader loses is the option they asked for and a warning they were given the wrong way round: told that twoFactorPage is a removed name to avoid, a reader will not try it, and a reader who finds it in the docs afterwards will assume the docs are stale. The draw also volunteered the workaround the option exists to replace - "If you want it to be configured once as a string, wrap it yourself — that is a three-line helper, and it is the closest you get." It is not the closest you get. The pattern is not this draw's alone: v7-c and v7-d, a different model family, produced the same inverted history independently, placing twoFactorPage in the 0.x line and calling the callback its replacement. Three of six draws name the right option and date it to the wrong side of its own introduction.

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 (measured from session.createdAt) Task 1, the primary probe, and the answer this battery was built to catch: a bare "no" on its own line - correct at 1.6.0 and later, and the answer that would have been wrong at 1.5.0. Discounted as evidence of recall, in advance and by rule. Both below-floor control arms also answered "no" (Claude Sonnet 5, and Claude Haiku 4.5 with a stated February 2025 cutoff), so the current semantics are reachable from below the boundary and a derivable outcome kills a pass rather than a failure (JOURNAL/031). On a binary question two controls agreeing is also two coins landing the same way. What is not discounted is that this draw's twin, v7-b, answered "Yes" from the identical prompt.
correctsession.freshAge (measured from session.createdAt) Task 5, the same surface asked as mechanism rather than as verdict, four tasks later, and graded independently by the pre-registration: "(a) createdAt. The freshness window is measured from when the session was created, not from updatedAt. This is the whole reason task 1 fails... (b) session: { freshAge: 0 } — zero disables the check rather than meaning 'never fresh'." Both halves correct, and internally consistent with its own task 1. It also volunteered the right default ("Moderate confidence on the default value being 24 hours"), which is correct at every release in the window.
correctsession.freshAge anchor option (does not exist) Task 3, the pre-registered control - a configuration option choosing the freshness anchor, which exists at no release. "No", correctly, with the reason stated: "freshAge is a duration, and the timestamp it is compared against is fixed by the framework." It then gave the honest workaround (disable with freshAge: 0 and gate the sensitive routes yourself) and added a design observation that is true and that no task asked for: "with updateAge sliding, 'measured from last use' makes the freshness gate almost never fire, which is probably not what the security team thinks they are agreeing to." That is a correct description of the pre-1.6.0 behaviour, offered by a draw that had just said the current one is createdAt-based.
correctcustomSession Task 4, the floor probe (1.0.0 custom session). customSession on the server with customSessionClient<typeof auth>() for the client types, plus the two real caveats - it runs on every session read, and it interacts with cookieCache. Passed, so the arm is informative on the surfaces above it.
contextstateless sessions Task 7, the attribution anchor, and a replication of better-auth/v2's charged finding rather than the dating error P6 predicted. Asked which release made stateless sessions possible, this draw did not misdate it - it denied the capability exists at all: "There isn't one. I know of no better-auth release that added stateless / self-contained sessions... if the premise of the question is that such a release exists, I think the premise is wrong, and I would want to be shown the changelog entry before believing otherwise." It then correctly listed the three things it is confused with (cookieCache, the jwt plugin, the bearer plugin). Stateless session management shipped in 1.4.0 (2025-11-22) and this subject was charged on that same denial in better-auth/v2 four days ago. Task 7 is belief data and never scores, so nothing is charged here; the replication is recorded because a belief that survives a second battery with different wording is a stronger measurement than the first one was.

Sources

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