feat(two-factor): add storeSecret hook for TOTP secret encryption - #11234
feat(two-factor): add storeSecret hook for TOTP secret encryption#11234Micheletto wants to merge 1 commit into
Conversation
|
@Micheletto is attempting to deploy a commit to the better-auth Team on Vercel. A member of the Team first needs to authorize it. |
|
This PR was automatically retargeted from |
|
There was a problem hiding this comment.
🔵 Needs a closer look
It combines the TOTP secret storage hook with broad, behavior- and schema-impacting changes (notably account identity/uniqueness), which raises migration and correctness risk beyond the stated PR scope.
Pull request overview
This PR introduces a pluggable storage cipher for the TOTP shared secret in the two-factor plugin (totpOptions.storeSecret) so deployments can replace the built-in XChaCha20-Poly1305 encryption with a custom, compliance-friendly mechanism (e.g. FIPS-validated module / KMS / HSM). In addition, it contains substantial broader changes across core account identity/schema behavior, schema validation, instrumentation, tooling, and docs.
Changes:
- Add
totpOptions.storeSecretplus helpers to encode/decode the stored TOTP secret, with startup-time configuration validation and expanded tests/docs. - Change the account identity model away from
issuer(e.g. OAuth account key resolution now uses{ providerId, accountId }) and update schema generation/tests/docs accordingly. - Introduce/extend runtime schema checking plumbing and per-instance OpenTelemetry span disabling, plus assorted tooling/docs and package versioning updates.
File summaries
| File | Description |
|---|---|
| packages/better-auth/src/plugins/two-factor/totp/index.ts | Adds storeSecret option, option validation, and secret encode/decode helpers. |
| packages/better-auth/src/plugins/two-factor/index.ts | Routes TOTP secret storage through the new helper during enrollment. |
| packages/better-auth/src/plugins/two-factor/two-factor.test.ts | Adds coverage for custom secret cipher round-trips and misconfiguration guards. |
| docs/content/docs/plugins/2fa.mdx | Documents storeSecret, including migration/legacy read considerations. |
| packages/better-auth/src/oauth2/account-key.ts | Switches OAuth account-key resolution to { providerId, accountId } (drops issuer). |
| packages/core/src/db/get-tables.ts | Removes the default account identity unique index and the issuer column from the schema builder. |
| packages/better-auth/src/db/internal-adapter.ts | Adds/relies on duplicate-detection behavior for account key lookups without DB uniqueness. |
| packages/core/src/types/context.ts | Exposes optional checkSchema on AuthContext. |
| packages/better-auth/src/context/create-context.ts | Wires checkSchema into the constructed auth context. |
| packages/better-auth/src/api/index.ts | Selects span runner per instance and awaits schema checks prior to request routing. |
Review details
- Files reviewed: 297/674 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| account: { | ||
| modelName: options.account?.modelName || "account", | ||
| indexes: mergeTableIndexes( | ||
| [ | ||
| { | ||
| fields: ["issuer", "accountId"], | ||
| unique: true, | ||
| }, | ||
| ], | ||
| account?.indexes, | ||
| ), | ||
| indexes: account?.indexes, | ||
| fields: { |
There was a problem hiding this comment.
This one is stale — it was reviewed against a diff that briefly contained 124 unrelated commits.
The retarget bot moved this PR from main to next for its minor changeset, but the branch was cut from main, and next sits 124 commits behind. For a window the PR therefore showed 674 files / 100 commits, including upstream's own account-identity work on main (resolveOAuthAccountKey, the issuer removal, get-tables.ts). None of that is mine. I have since rebased onto next, and the diff is back to 6 files / 1 commit / +520-17:
.changeset/totp-store-secret-hook.md
.cspell/tech-terms.txt
docs/content/docs/plugins/2fa.mdx
packages/better-auth/src/plugins/two-factor/index.ts
packages/better-auth/src/plugins/two-factor/totp/index.ts
packages/better-auth/src/plugins/two-factor/two-factor.test.ts
Neither packages/core/src/db/get-tables.ts nor packages/better-auth/src/oauth2/account-key.ts is touched by this PR, so there is nothing here to split out or document. The scoping concern was reasonable given what the diff showed at the time.
Happy to re-run a review against the current head (fd87fe2) if that is useful.
| } | ||
|
|
||
| return { issuer, accountId }; | ||
| return { providerId: provider.id, accountId }; | ||
| } |
There was a problem hiding this comment.
This one is stale — it was reviewed against a diff that briefly contained 124 unrelated commits.
The retarget bot moved this PR from main to next for its minor changeset, but the branch was cut from main, and next sits 124 commits behind. For a window the PR therefore showed 674 files / 100 commits, including upstream's own account-identity work on main (resolveOAuthAccountKey, the issuer removal, get-tables.ts). None of that is mine. I have since rebased onto next, and the diff is back to 6 files / 1 commit / +520-17:
.changeset/totp-store-secret-hook.md
.cspell/tech-terms.txt
docs/content/docs/plugins/2fa.mdx
packages/better-auth/src/plugins/two-factor/index.ts
packages/better-auth/src/plugins/two-factor/totp/index.ts
packages/better-auth/src/plugins/two-factor/two-factor.test.ts
Neither packages/core/src/db/get-tables.ts nor packages/better-auth/src/oauth2/account-key.ts is touched by this PR, so there is nothing here to split out or document. The scoping concern was reasonable given what the diff showed at the time.
Happy to re-run a review against the current head (fd87fe2) if that is useful.
There was a problem hiding this comment.
1 issue found across 6 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="docs/content/docs/plugins/2fa.mdx">
<violation number="1" location="docs/content/docs/plugins/2fa.mdx:611">
P3: The warning states the indexed secret column is `varchar(255)` on MSSQL, but the migration type map (get-migration.ts mssql branch) gives indexed, non-unique, non-sortable string fields `varchar(8000)`, not 255. On MSSQL the column is actually wider, so the warning is overstated there; clarify that only MySQL (and Prisma's 191 default) impose the tighter limit.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
|
|
||
| <Callout type="warn"> | ||
| The stored value must fit the `secret` column. It is indexed, so it is | ||
| generated as `varchar(255)` on MySQL/MSSQL (`varchar(191)` under Prisma). |
There was a problem hiding this comment.
P3: The warning states the indexed secret column is varchar(255) on MSSQL, but the migration type map (get-migration.ts mssql branch) gives indexed, non-unique, non-sortable string fields varchar(8000), not 255. On MSSQL the column is actually wider, so the warning is overstated there; clarify that only MySQL (and Prisma's 191 default) impose the tighter limit.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/content/docs/plugins/2fa.mdx, line 611:
<comment>The warning states the indexed secret column is `varchar(255)` on MSSQL, but the migration type map (get-migration.ts mssql branch) gives indexed, non-unique, non-sortable string fields `varchar(8000)`, not 255. On MSSQL the column is actually wider, so the warning is overstated there; clarify that only MySQL (and Prisma's 191 default) impose the tighter limit.</comment>
<file context>
@@ -566,10 +566,108 @@ export const twoFactorTotpOptionsType = {
+
+<Callout type="warn">
+ The stored value must fit the `secret` column. It is indexed, so it is
+ generated as `varchar(255)` on MySQL/MSSQL (`varchar(191)` under Prisma).
+ Wrapped keys returned by a KMS can exceed that and will fail on enrollment.
+ Check the column width before rolling this out.
</file context>
| generated as `varchar(255)` on MySQL/MSSQL (`varchar(191)` under Prisma). | |
| + generated as `varchar(255)` on MySQL (`varchar(191)` under Prisma). On MSSQL it is wider (`varchar(8000)`), but check your column width before rolling this out. |
There was a problem hiding this comment.
Valid — fixed in fd87fe2. I verified it rather than taking the suggestion directly, and the picture is a bit wider than either of us had it.
The width only narrows when the column is in a resolved index, and getDatabaseIndexStringLength returns undefined when it is not. Field-level index: true does not produce a resolved index: resolveDatabaseSchemaIndexes only validates field-level index names, while the entries come from resolveDatabaseTableIndexes(source), i.e. table-level indexes. twoFactor declares none. Checked empirically against the built output:
twoFactor resolved indexes: []
secret in a resolved index? false
So tableIndexStringLength is undefined for secret, and the getType fallbacks apply:
| Dialect | Generated | Why |
|---|---|---|
| Prisma + MySQL | varchar(191) |
index([secret(length: 191)]) in the Prisma generator |
| MySQL | varchar(255) |
field.index branch |
| MSSQL | varchar(8000) |
not unique/sortable/references, so the varchar(8000) fallback — your point |
| PostgreSQL / SQLite | text |
unbounded |
So MSSQL is 8000 as you say, MySQL is 255, and the genuinely tight one is Prisma+MySQL at 191. The docs and the option JSDoc now list all four rather than collapsing MySQL and MSSQL together.
Worth noting the original wording was wrong in the other direction too: had secret been in a resolved index, MySQL would compute 191 and MSSQL 255 from the byte budgets — so "255 on MySQL/MSSQL" was not right under either path.
31a7196 to
0032293
Compare
|
Rebased onto The retarget bot correctly moved this to Only conflict was Re-verified against
Run in a Node 24 container with the
On @greptile-apps' one finding: agreed, and it matches what an internal reviewer independently raised. |
|
Fold it into this PR. The ordering is directly relevant to the new Tip: You can customize Greptile's behavior for this repo with |
0032293 to
fd87fe2
Compare
The TOTP shared secret is encrypted at rest with XChaCha20-Poly1305, which is not a FIPS-approved algorithm, so deployments constrained to FIPS 140-3 cannot use TOTP at all. The built-in cipher also derives its key by hashing the configured secret directly, which is not an approved KDF. Add an opt-in `totpOptions.storeSecret` hook so a deployment can hold the secret in a validated cryptographic module or a KMS/HSM instead. This mirrors the existing `storeOTP` and `storeBackupCodes` hooks in the same plugin, minus the modes that discard recoverability, and the built-in cipher remains the default, so behavior is unchanged unless the option is set. `encrypt` and `decrypt` are required together, and an unrecognized value throws at startup. Falling back to the built-in cipher when the pair is incomplete would store the secret under a different cipher than the one used to read it, and silently using a non-approved cipher would defeat the reason for configuring a custom one. TOTP code generation itself is unchanged: it uses HMAC-SHA-1 via WebCrypto, which is approved for HMAC under SP 800-131A Rev. 2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fd87fe2 to
0e1435e
Compare
Implements #11233.
Why
The TOTP shared secret is encrypted at rest with XChaCha20-Poly1305 (
@noble/ciphers), viasymmetricEncryptinpackages/better-auth/src/crypto/index.ts. ChaCha20-Poly1305 is not a FIPS-approved algorithm, so a deployment constrained to FIPS 140-3 cannot use the TOTP second factor at all. The same function also derives its key by hashing the configured secret directly, which is not an approved KDF.Scoping note: TOTP code generation itself was never the problem.
createOTPfrom@better-auth/utilscomputes HMAC-SHA-1 through WebCrypto, and HMAC-SHA-1 remains approved for HMAC use under SP 800-131A Rev. 2. The secret is 32 characters from a 64-character alphabet (192 bits). So the only blocker was encryption of the secret at rest, and that is the only thing this PR changes. In particular it does not reimplement HOTP to get SHA-256, which would break real authenticator apps for no compliance benefit.What
An opt-in
totpOptions.storeSecret:This deliberately mirrors the two hooks that already exist in this plugin rather than introducing a new shape:
storeOTP"plain" | "encrypted" | "hashed" | {hash} | {encrypt,decrypt}storeBackupCodes"plain" | "encrypted" | {encrypt,decrypt}storeSecret(new)"encrypted" | {encrypt,decrypt}The TOTP secret was the only value this plugin stores with no pluggable cipher.
"plain"and"hashed"are intentionally absent — the secret must stay recoverable to generate codes, and it is a long-lived credential rather than a single-use code. Both the JSDoc and the docs now say so, since otherwise mirroringstoreOTP: "plain"produces an unexplained startup error.The built-in cipher remains the default. Behavior is unchanged unless the option is set.
encryptanddecryptare required together, and an unrecognized value throwsBetterAuthErrorat construction. This is the one intentional divergence from the sibling hooks: falling back to the built-in cipher when the pair is incomplete would write the secret under one cipher and read it under another, and silently using a non-approved cipher would defeat the entire reason for configuring a custom one. Validating options in the plugin factory followsjwt(), which does the same.Files
plugins/two-factor/totp/index.ts— the option,encodeTOTPSecret/decodeTOTPSecret, and the paired-config guardplugins/two-factor/index.ts— the encrypt site in/two-factor/enableroutes through the helperplugins/two-factor/two-factor.test.ts— testsdocs/content/docs/plugins/2fa.mdx— option docs and a migration guideAll three sites that touch the TOTP secret ciphertext are converted (enable,
get-totp-uri,verify-totp); nothing outsideplugins/two-factor/readstwoFactor.secret.Migration
Existing secrets are not re-encrypted, and are only rewritten when a user re-enrolls, so
decrypthas to read both formats or existing users cannot sign in. The docs cover this with a tested recipe that tags new ciphertext and branches on the tag, rather than relying on the previous cipher throwing — that holds for an AEAD but a non-AEAD mode can return garbage instead of failing. There is also a callout forsecrets: [...]rotation, where stored values are$ba$<version>$<hex>envelopes that a plain string key cannot decrypt.Lazy re-encryption on read is a deliberate follow-up, not included here.
Testing
Verified on this branch rebased onto
main(9b9638ec), in a Node 24 container with thedocker composeservices CI uses:pnpm buildpnpm lint/lint:dependencies/lint:packages/lint:spell/format:check/lint:typespnpm typecheck/typecheck:consumers(32/32) /typecheck:distvitest run packages/better-auth/src/plugins/two-factor/main(+11)vitest run packages/better-auth/New tests cover: custom-cipher round trip using a real AES-256-GCM WebCrypto implementation and asserting the built-in cipher provably did not produce the stored value; the TOTP URI path; rejection of a non-matching code; the unchanged default and the explicit
"encrypted"sentinel; all four misconfiguration guards (missingencrypt, missingdecrypt, unrecognized string,null, and a non-serializable value); and the documented legacy-migration recipe reading a row written by the built-in cipher.Known limitations, disclosed rather than fixed here
getTOTPURIdecrypts before the password check. This ordering predates the PR and is unchanged, but with this hook it means an integrator's KMS/HSM decrypt is now reachable one step ahead of that gate. The endpoint is behindsessionMiddlewareand the URI is only built after the check, so it is not a disclosure — but movingdecodeTOTPSecretbelowshouldRequirePasswordis behavior-preserving and worth doing separately.twoFactor.secretisindex: true, so it is generated asvarchar(255)on MySQL/MSSQL andvarchar(191)under Prisma. The built-in cipher's 144 hex characters always fit; a wrapped KMS key may not. Documented in both the JSDoc and the docs, with no runtime assertion.storeBackupCodes: { encrypt }alone currently writes ciphertext and reads it back throughsafeJSONParse, yieldingnull— a silent failure wherestoreSecretnow fails loudly. Pre-existing and out of scope, but the inconsistency is worth an issue.Separately,
account.encryptOAuthTokensis documented as using "AES-256-GCM" (packages/core/src/types/init-options.ts) but routes through the same XChaCha20-Poly1305symmetricEncrypt. Not touched here, but the docs are inaccurate.Process note
CONTRIBUTING.mdasks for features to be discussed in an issue first, so I opened #11233 alongside this and would rather you redirect the design there than review a shape you don't want. This PR is the concrete proposal for that discussion; close it without ceremony if the direction is wrong. The changeset isminor, so I believe the retarget automation will move this tonext— happy to re-cut it aspatchagainstmaininstead if you'd prefer, since the change is opt-in and existing users need take no action.🤖 Generated with Claude Code
Summary by cubic
Implements #11233 by adding an opt-in
totpOptions.storeSecrethook for custom TOTP secret encryption, including FIPS 140-3 validated modules and KMS/HSM integrations. TOTP previously always used the built-in XChaCha20-Poly1305 cipher; that remains the default, so existing behavior is unchanged unless the option is configured.Migration
"encrypted"or a custom{ encrypt, decrypt }pair; plaintext and hashed modes are not supported because the secret must remain recoverable.decryptimplementations must support the built-in format until users re-enroll.Tests
Written for commit 0e1435e. Summary will update on new commits.