Skip to content

feat(two-factor): add storeSecret hook for TOTP secret encryption - #11234

Open
Micheletto wants to merge 1 commit into
better-auth:nextfrom
networkninja:feat/totp-store-secret-hook
Open

feat(two-factor): add storeSecret hook for TOTP secret encryption#11234
Micheletto wants to merge 1 commit into
better-auth:nextfrom
networkninja:feat/totp-store-secret-hook

Conversation

@Micheletto

@Micheletto Micheletto commented Sep 9, 2026

Copy link
Copy Markdown

Implements #11233.

Why

The TOTP shared secret is encrypted at rest with XChaCha20-Poly1305 (@noble/ciphers), via symmetricEncrypt in packages/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. createOTP from @better-auth/utils computes 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:

twoFactor({
  totpOptions: {
    storeSecret: { encrypt, decrypt },
  },
})

This deliberately mirrors the two hooks that already exist in this plugin rather than introducing a new shape:

Option Modes
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 mirroring storeOTP: "plain" produces an unexplained startup error.

The built-in cipher remains the default. Behavior is unchanged unless the option is set.

encrypt and decrypt are required together, and an unrecognized value throws BetterAuthError at 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 follows jwt(), which does the same.

Files

  • plugins/two-factor/totp/index.ts — the option, encodeTOTPSecret / decodeTOTPSecret, and the paired-config guard
  • plugins/two-factor/index.ts — the encrypt site in /two-factor/enable routes through the helper
  • plugins/two-factor/two-factor.test.ts — tests
  • docs/content/docs/plugins/2fa.mdx — option docs and a migration guide

All three sites that touch the TOTP secret ciphertext are converted (enable, get-totp-uri, verify-totp); nothing outside plugins/two-factor/ reads twoFactor.secret.

Migration

Existing secrets are not re-encrypted, and are only rewritten when a user re-enrolls, so decrypt has 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 for secrets: [...] 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 the docker compose services CI uses:

Gate Result
pnpm build pass
pnpm lint / lint:dependencies / lint:packages / lint:spell / format:check / lint:types 6/6 pass
pnpm typecheck / typecheck:consumers (32/32) / typecheck:dist 3/3 pass
vitest run packages/better-auth/src/plugins/two-factor/ 104 passed, up from 93 on main (+11)
vitest run packages/better-auth/ 2710 passed, 1 skipped, 1 todo (101 files)

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 (missing encrypt, missing decrypt, 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

  • getTOTPURI decrypts 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 behind sessionMiddleware and the URI is only built after the check, so it is not a disclosure — but moving decodeTOTPSecret below shouldRequirePassword is behavior-preserving and worth doing separately.
  • Column width. twoFactor.secret is index: true, so it is generated as varchar(255) on MySQL/MSSQL and varchar(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.
  • Backup codes and the 2FA OTP still default to the built-in cipher. Both already have their own hooks, so a FIPS deployment must set all three; only the TOTP gap was missing. Related: storeBackupCodes: { encrypt } alone currently writes ciphertext and reads it back through safeJSONParse, yielding null — a silent failure where storeSecret now fails loudly. Pre-existing and out of scope, but the inconsistency is worth an issue.

Separately, account.encryptOAuthTokens is documented as using "AES-256-GCM" (packages/core/src/types/init-options.ts) but routes through the same XChaCha20-Poly1305 symmetricEncrypt. Not touched here, but the docs are inaccurate.

Process note

CONTRIBUTING.md asks 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 is minor, so I believe the retarget automation will move this to next — happy to re-cut it as patch against main instead 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.storeSecret hook 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

  • Accepts "encrypted" or a custom { encrypt, decrypt } pair; plaintext and hashed modes are not supported because the secret must remain recoverable.
  • Requires both custom functions and rejects invalid configuration at plugin startup instead of silently falling back.
  • Existing secrets are not re-encrypted, so custom decrypt implementations must support the built-in format until users re-enroll.
  • Documents tagged ciphertext migration, secret rotation, and database column size considerations.

Tests

  • Covers custom encryption, TOTP URI generation, code verification, legacy ciphertext fallback, default storage, and configuration validation.

Written for commit 0e1435e. Summary will update on new commits.

Review in cubic

Copilot AI lite review requested due to automatic review settings September 9, 2026 17:59
@Micheletto
Micheletto requested review from a team as code owners September 9, 2026 17:59
@Micheletto
Micheletto requested review from Bekacru and gustavovalverde and removed request for a team September 9, 2026 17:59
@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown

@Micheletto is attempting to deploy a commit to the better-auth Team on Vercel.

A member of the Team first needs to authorize it.

@better-release better-release Bot added security 2FA, rate limiting, captcha, HIBP docs Documentation, demos labels Sep 9, 2026
@better-release
better-release Bot changed the base branch from main to next September 9, 2026 18:00
@better-release
better-release Bot requested review from a team as code owners September 9, 2026 18:00
@better-release

better-release Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

This PR was automatically retargeted from main to next because it contains a minor changeset. The main branch only accepts patch (bug fix) changes. Features and breaking changes go through next for beta testing before promotion to stable.

@greptile-apps

greptile-apps Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds configurable encryption and decryption hooks for stored TOTP secrets while preserving the built-in cipher as the default.

  • Validates custom cipher configuration during plugin construction.
  • Routes enrollment, TOTP URI generation, and TOTP verification through the configured cipher.
  • Documents ciphertext migration and storage-width constraints.
  • Adds custom-cipher, compatibility, validation, and default-behavior tests.

Confidence Score: 5/5

The PR appears safe to merge with no outstanding findings.

The correction since the previous review accurately distinguishes Better Auth migration column types from Prisma’s MySQL @db.Text representation and 191-character index prefix; the previous documentation thread is resolved, and no new failures or rule violations were found.

Reviews (4): Last reviewed commit: "feat(two-factor): add storeSecret hook f..." | Re-trigger Greptile

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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.storeSecret plus 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.

Comment on lines 251 to 254
account: {
modelName: options.account?.modelName || "account",
indexes: mergeTableIndexes(
[
{
fields: ["issuer", "accountId"],
unique: true,
},
],
account?.indexes,
),
indexes: account?.indexes,
fields: {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines -18 to 41
}

return { issuer, accountId };
return { providerId: provider.id, accountId };
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread docs/content/docs/plugins/2fa.mdx Outdated

<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).

@cubic-dev-ai cubic-dev-ai Bot Sep 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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.
Fix with cubic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Micheletto
Micheletto force-pushed the feat/totp-store-secret-hook branch from 31a7196 to 0032293 Compare September 9, 2026 19:38
@Micheletto

Copy link
Copy Markdown
Author

Rebased onto next and force-pushed.

The retarget bot correctly moved this to next for the minor changeset, but the branch was cut from main, and next is currently 124 commits behind it — so the retarget turned this into 674 files / 100 commits / +46321-17549. Rebasing onto next puts it back to 6 files / 1 commit / +520-17. Apologies for the noise; I should have branched from next once I saw the changeset was minor.

Only conflict was .cspell/tech-terms.txt: describedby is a main-only entry, so on next this adds FIPS alone.

Re-verified against next rather than carrying the main numbers over, since it is a materially older tree:

Gate Result
pnpm build pass
lint / lint:dependencies / lint:packages / lint:spell / format:check / lint:types 6/6 pass
typecheck / typecheck:consumers (32/32) / typecheck:dist 3/3 pass
vitest run packages/better-auth/src/plugins/two-factor/ 99 passed, up from 88 on next (+11)
vitest run packages/better-auth/ 2590 passed, 1 todo (97 files)

Run in a Node 24 container with the postgres / postgres-kysely / mongodb services from docker-compose.yml. The +11 delta is identical on main and on next, so the tests do not depend on anything branch-specific. Not covered locally: the Node 22 matrix leg, e2e.yml, and demo.yml.

CI, E2E, Demo and Verify Changesets are all sitting at action_required pending workflow approval for a first-time fork contributor, so none of the real gates have run here yet — happy to keep iterating once they do.

On @greptile-apps' one finding: agreed, and it matches what an internal reviewer independently raised. getTOTPURI calls decodeTOTPSecret before shouldRequirePassword, so an authenticated caller who supplies no password drives one KMS/HSM decrypt before being rejected. The ordering predates this PR, but the hook makes the consequence concrete. It is a behaviour-preserving four-line move; I have deliberately left it out to keep this diff to one reviewable change, and will do it as a follow-up unless you would rather it were folded in here — say which you prefer.

@greptile-apps

greptile-apps Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fold it into this PR. The ordering is directly relevant to the new storeSecret.decrypt hook: moving decodeTOTPSecret below shouldRequirePassword prevents an authenticated caller without a valid password from invoking an integrator’s KMS/HSM before the password gate, while preserving successful-request behavior. Please include a regression test asserting that decrypt is not called when the password is missing.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

@Micheletto
Micheletto force-pushed the feat/totp-store-secret-hook branch from 0032293 to fd87fe2 Compare September 9, 2026 19:53
Comment thread docs/content/docs/plugins/2fa.mdx Outdated
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation, demos security 2FA, rate limiting, captcha, HIBP

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants