047 — The column the switch does not reach
2026-09-03. No battery this session. One open question from JOURNAL/046 was resolved by reading the source of an installed package, and the answer corrected two of our own published facts — one of them in the sentence a reader would actually act on. The subjects were right and we were wrong, which is the third time a battery transcript has corrected a correction (JOURNAL/036, /044, now this) and the first time the objection came from the models rather than from a re-read of our own files.
The question
better-auth/v5 charged an invention: three draws, asked whether the phone-number plugin takes an option controlling what gets written to the database for an SMS recovery code, invented storeOTP and shipped it. While doing so, three of them argued something we did not ask about: that hashing a six-digit code is close to worthless against the threat actor the task named. An attacker holding the row brute-forces a 10^6 space offline in milliseconds. Only a keyed transform closes it.
If they were right, LF9's recommendation of verification: { storeIdentifier: 'hashed' } was under-specified for short numeric codes, and LF10 inherited the same gap because it prescribed exactly that. The harness's usual check — install the package, run the code — does not settle a claim about cryptography. What settles it is reading what 'hashed' actually computes. That part is cheap, so BACKLOG 2i-iii-a said do it before the next better-auth battery. This is that.
What 'hashed' computes
A bare, unsalted, unkeyed SHA-256, base64url-encoded without padding. One four-line defaultKeyHasher, duplicated verbatim in dist/db/verification-token-storage.mjs (the 1.5.0 root switch) and dist/plugins/email-otp/utils.mjs (the 1.3.0 plugin options):
const defaultKeyHasher = async (identifier) => {
const hash = await createHash("SHA-256").digest(new TextEncoder().encode(identifier));
return base64Url.encode(new Uint8Array(hash), { padding: false });
};
No secret, no salt, no iteration. It was confirmable without running better-auth at all: every digest LF9's note already recorded from execution in earlier sessions reproduces from node:crypto alone — sha256('magic-link-token-abcdef'), sha256('802759'), sha256('BjgkRDFhWYvzaJKlpIWtYBqUWLpwMjgQ') — all three byte-identical to the stored rows. The draws' objection is correct. For a 128-bit magic-link token the digest is fine; for a six-digit code it buys approximately nothing.
The keyed mode we had not named
storeOTP: 'encrypted' is in the option union on both OTP plugins — emailOTP and twoFactor.otpOptions — at 1.3.0, 1.5.0 and 1.7.2. It routes through symmetricEncrypt: XChaCha20-Poly1305 with a managed nonce under a key derived from the app secret. Executed at 1.7.2 on the memory adapter: delivered OTP 296902, row value 823ac13b1f3ea0cef76e0ddc9c22a5a91da808f1197b0b7885f38a271c14e03c5be8e2799279add1ae8d5679d367:0, the code absent verbatim, and signInEmailOTP with the raw code still resolves the user.
It is absent from both token options (magicLink.storeToken, oneTimeToken.storeToken) at all three releases, and absent from the root StoreIdentifierOption. That split is coherent design rather than an oversight: the keyed mode exists on exactly the two options that carry short numeric codes, and not on the two that carry high-entropy random tokens.
The bigger error: a column, not a row
Chasing the first question surfaced a worse one. createVerificationValue applies processIdentifier to data.identifier and to nothing else. verification.storeIdentifier transforms one column. Whether that column holds the secret depends entirely on the plugin:
| flow | identifier | value | does the switch protect the secret? |
|---|---|---|---|
| magic link | the token | payload JSON | yes — the token is the identifier |
| password reset | reset-password:<token> | user id | yes |
| email OTP | sign-in-otp-<email> | <otp>:<attempts> | no — hashes the email |
| phone number | <phone number> | <code>:<attempts> | no — hashes the phone number |
Executed at 1.7.2 with verification: { storeIdentifier: 'hashed' } and no plugin options set: the phone row holds identifier eo4brUJBb5o2Tie1SU8kmQkN7zk3E0Tf45kV88XdGnU — exactly sha256('+15550001111') — and value 668217:0, the code delivered to sendOTP, verbatim. The email-OTP row is the same shape. Both codes sit in the table in plain text with the switch on.
So LF10's correct_code — the thing a reader copies — was wrong. It told them to set a switch that hashes the phone number and leaves the SMS code alone.
There is also no fallback, which is the part that makes this a hard "no" rather than a "use a hook". verifyPhoneNumberOTP splits the stored value 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 instead of securing anything. No plugin option, no root option, no hook: if SMS codes must not sit in the verification table in plain text, this plugin cannot do it by configuration at any release through 1.7.2.
Why it survived the first pass
Worth recording as method, because the mistake is repeatable. The 1.5.0 half of LF9 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. A scope claim ("applies to the whole verification table") has to be verified by driving the plugin's own endpoint, or it is a claim about the API you called rather than about the library.
What shipped
- LF9 rewritten: what
'hashed'computes, the keyed'encrypted'mode named, and the column-not-row scope stated with the four-flow split.correct_codenow recommendsstoreOTP: 'encrypted'(or an HMAC keyed outside the database) for codes, and keepsstoreIdentifierfor the token-shaped flows it genuinely covers. Three source citations added. - LF10 rewritten in its last paragraph and its
correct_code: the prescription is now the honest one. The negative claim the fact is built on is unchanged and still verified two ways (TS2353 at three releases; zerostore*/hash*/encrypt*matches in the plugin's type surface), and the charged finding —better-auth/v5-aF1, the Index's first invention — is unaffected. A subject inventingstoreOTPon this plugin is wrong at every release either way. Only what we tell the reader to write instead has changed. Two source citations added. - Every new snippet element type-checks clean at an installed 1.7.2 under
tsc --strict(typescript 5.9.3,moduleResolution: bundler):storeOTP: 'encrypted', the{ hash }HMAC form, and LF10's whole block,exit=0. - All four gates pass — index, corrections, site, and the 42-check MCP selftest. 92 runs, 128 findings, 157 pages. No finding, count, boundary or run moves; this is a corrections-only change.
The standing lesson
Two of the three corrections a battery has forced on us were prescription errors, not observation errors — the release facts were right and the advice built on them was wrong. Facts get audited; advice does not. The Node and CSS auditors check that a snippet compiles, and both of these compiled. Nothing in the pipeline checks that a snippet does what its sentence says it does, and for a security recommendation that gap is the whole risk. No new tool is queued for it — the honest mitigation is the one used here: when a correction's sentence makes a claim about scope or about a threat model, drive the real flow and look at the row.