twofactor

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

README

github.com/ubgo/auth/plugin/twofactor

Adds authenticator-app two-factor sign-in (TOTP, as in Google Authenticator, 1Password or Authy) with single-use recovery codes, on top of every sign-in method the app has.

Install

go get github.com/ubgo/auth

It is part of the core module and adds no dependency beyond it.

Wire it

From examples/two-factor:

a, err := auth.New(
	auth.WithDB(db, sqlb.SQLiteDialect{}),
	auth.WithBaseURL(baseURL),
	auth.WithPlugins(
		email.New(email.Login(), email.Recover(), email.WithMailer(m)),
		password.New(),
		twofactor.New(twofactor.WithEncryptionKey(key), twofactor.Issuer(appName), twofactor.AccountLabel(label)),
	),
)

It needs a sign-in method (password, magic link, OAuth, SMS code…); no sign-in plugin knows it exists. Once a user confirms a factor, every one of those methods returns a pending session that signs nobody in until verify passes. A passkey sign-in is a full session without it, because a passkey with user verification is two factors on its own; one whose device skipped user verification is single-factor (SignInAttempt.SingleFactor) and is asked for the factor.

auth.New fails with ErrNoEncryptionKey (twofactor: pass twofactor.WithEncryptionKey with a 32-byte key, or auth.WithSecret) unless the key is exactly auth.SealKeySize (32) bytes or the app set auth.WithSecret, from which the key is derived under auth.KeyLabelTwoFactor; a rotation of the secret (auth.WithSecret(new, old)) keeps every enrolment opening.

Options

Option What it does Default
WithEncryptionKey(key) AES-256 key that seals TOTP secrets at rest. Losing it disables every enrolled factor; users then sign in with a recovery code. required, unless a Sealer or auth.WithSecret is set: then derived from the secret
WithSealer(s) An auth.Sealer (Vault transit, a cloud KMS) that seals TOTP secrets and StoreEncrypted recovery codes. Order for TOTP secrets: WithEncryptionKey, WithSealer, auth.WithSealer, the derived key; for recovery codes the same without the key. A seal error fails the enrolment or the new code set; an open error fails the check. Rows sealed before the switch open only through a Sealer that can open them. none: auth.WithSealer, else the derived key
WithSecurityKeys(s) Security keys (WebAuthn) as a second factor beside the code methods, stored by a SecurityKeyStore: passkey.(*Plugin).SecurityKeys(), with the passkey plugin registered too. Adds security_key to Methods and registers the security-key routes. off
RequireEnrolment(rule) Users the EnrolmentRule names who have no factor on get a session with auth.TaskSetupMFA pending, which reaches only status, enroll, confirm and the security-key registration until a factor is on; then a new token is issued. off
Issuer(name) App name the authenticator app shows. Written into the app at enrollment. "App" (DefaultIssuer)
AccountLabel(fn) Account name the authenticator app shows next to the issuer, such as the email address. fn is a LabelFunc, func(ctx, a *auth.Auth, u auth.User) string: it receives the core, so it can look the address up with a.Identities (see the example); an empty result falls back to the default. the user's name, or the user id when the name is empty
RecoveryCodeCount(n) Recovery codes handed out at confirm or regenerate. 10 (DefaultRecoveryCodes)
RecoveryCodeLength(n) Recovery code length in base32 characters, at least MinRecoveryCodeLength (8); shown in groups of four. 16 (DefaultRecoveryCodeLength)
RecoveryCodeGenerator(fn) The app's own codes (words, a house format) instead of random ones; compared after trimming, upper-casing and removing dashes and spaces. Empty or duplicate codes fail confirm with ErrBadRecoveryCodes. random base32
WithDigits(n) Code length, 6 (Digits) or 8 (LongDigits). Written into the authenticator app at enrolment: changing it later breaks enrolled apps. 6
WithPeriod(d) Time step, whole seconds. Same caveat as WithDigits. 30 s (Step)
Lockout(maxFailures, d) Wrong codes in a row that lock the factor, and for how long. Each lock is one auth.SecurityLockout event on the security event stream when a sink is registered; the refusals while it lasts are not. 5, 15 minutes (MaxFailures, LockDuration)
DeliveredCodes() Lets users enrol MethodDelivered: a one-time code mailed or texted to a verified identity at each sign-in, through the identifier plugins' delivery. Adds the send route. Off by default: a code in a mailbox is a weaker factor than an authenticator app. TOTP only
WithoutTOTP() Removes the authenticator-app method; needs DeliveredCodes() or auth.New refuses the plugin (ErrNoMethod). Factors already enrolled with TOTP keep working. TOTP on
TrustDevices(maxAge) "Remember this device": a verify with trustDevice: true hands the transport a device token (cookie __Host-auth_device), and a sign-in carrying it skips the second factor for this user until maxAge from that moment (0 means 30 days, at most a year, ErrBadTrustAge otherwise). Never renewed by use. Adds the devices and forget routes. Off by default: every trusted device is a place where the second factor does not apply. off
CodeTTL(d) How long a delivered code works. The code's length is the core's (auth.WithCodeLength). 5 minutes (DefaultCodeTTL)
SkipVerificationOnEnable() better-auth's skipVerificationOnEnable: enroll turns the factor on at once and returns the recovery codes (EnrollResult.Enabled, RecoveryCodes); no confirm step. A delivered-code factor sends no first code (better-auth's method: "otp") but still needs a verified identity (ErrNoChannel). Risk: nothing proves the app holds the secret or the mailbox receives codes, so a user who mis-scanned the QR code is locked out at the next sign-in until they use a recovery code. off: confirm with a first code
ReadableTOTPURI() Adds totp-uri (better-auth's get-totp-uri): the user reads the otpauth:// URI back on a fresh session, with the password when they have one. Risk: the URI is the secret, so for a user without a password a stolen fresh session clones the authenticator. off: a lost QR code during setup means enrolling again
RecoveryCodeStorage(s) StoreHashed keeps only a SHA-256 hash of each code; StoreEncrypted (better-auth's default) also keeps each code sealed with AES-256-GCM under a key derived from auth.WithSecret under KeyLabelRecoveryCodes, bound to the user id (or with WithSealer / auth.WithSealer when one is set), and adds view-recovery-codes. Needs a Sealer or auth.WithSecret (ErrRecoveryStorageNeedsSecret); an unknown mode is ErrBadRecoveryStorage. Risk: a database leak plus the secret gives every unused code. Applies to codes made from then on. StoreHashed
WithPolicy(fn) Decides, per sign-in of a user with a confirmed factor, whether the code is asked for. fn receives a PolicyRequest: the user, client, sign-in operation and method (password, oauth, sso …) and DeviceTrusted; an error asks for the code (fails closed). DefaultPolicy is better-auth's rule: ask after the password sign-ins (CredentialSignIns: password.sign-in, password.change-expired, and password.reauthenticate, so a re-authentication asks what a password sign-in asks), after a direct Go CreateSession, and after a sign-in the method reports as single-factor (SignInAttempt.SingleFactor: a passkey whose device skipped user verification), not after OAuth, SSO, magic links, user-verified passkeys or one-time codes. AskEverySignIn asks after every method. DefaultPolicy: password sign-ins ask, except from a trusted device
RequirePassword() better-auth's password on enable, disable and generate-backup-codes: enroll, disable, recovery-codes and view-recovery-codes also ask for the password of a user who has one, checked by the registered password plugin (found through PasswordChecker). A user without a password passes on the fresh session (better-auth's allowPasswordless). off: the fresh session is the proof

TOTP defaults are what every authenticator app supports: 30 second step (Step), 6 digits (Digits), one step of clock skew either way (Skew, not configurable), HMAC-SHA1, 160-bit secret. auth.New refuses WithDigits outside 6 and 8 (ErrBadDigits), a WithPeriod that is not whole positive seconds (ErrBadPeriod), a RecoveryCodeLength under the minimum (ErrBadRecoveryLen) and a Lockout with no allowed failure or no duration (ErrBadLockout).

Routes and Go API

Routes are mounted under the authhttp prefix (/auth in the examples). All but the two GET reads are POST with a JSON body; codes travel as {"code":"123456"}.

Route Go method Requirement Rate limit
POST /auth/twofactor/enroll Enroll(ctx), or EnrollWith(ctx, EnrollRequest{Method, Issuer, Password}), returns EnrollResult{Method, Secret, URL, Enabled, RecoveryCodes} FreshSignIn none
GET /auth/twofactor/status Status(ctx) returns Status{Enabled, Method, Methods, TrustDevices} SignedInOrPending (the page after a pending sign-in learns which form to show; a settings page whether the factor is on) none
POST /auth/twofactor/send Send(ctx) SignedInOrPending (the pending sign-in step, or a signed-in user before disable or recovery-codes) 10 per 10 minutes per IP (sendIPLimit); exists only with DeliveredCodes
POST /auth/twofactor/confirm Confirm(ctx, code) returns RecoveryCodes{Codes} FreshSignIn lockout; 30/minute per IP
POST /auth/twofactor/disable Disable(ctx, code), or DisableWith(ctx, CodeRequest{Code, Password}) FreshSignIn lockout; 30/minute per IP
POST /auth/twofactor/verify Verify(ctx, code) PendingSecondFactor lockout; 30/minute per IP (codeIPLimit)
POST /auth/twofactor/remove-factor RemoveFactor(ctx, RemoveFactorRequest{Method, ID, Proof, Password}) returns RemoveFactorResult{Enabled}; removing the last factor is disable FreshSignIn lockout; 30/minute per IP
POST /auth/twofactor/security-key/register-begin SecurityKeyRegisterBegin(ctx, SecurityKeyRegisterBeginRequest{AuthenticatorAttachment, Password}) returns SecurityKeyChallenge{Ceremony, Options}; with WithSecurityKeys FreshSignIn none
POST /auth/twofactor/security-key/register-finish SecurityKeyRegisterFinish(ctx, SecurityKeyRegisterFinishRequest{Ceremony, Credential, Name, Proof}) returns SecurityKeyRegistered{Key, RecoveryCodes}; Proof required while a factor is on FreshSignIn lockout; 30/minute per IP
POST /auth/twofactor/security-key/challenge SecurityKeyChallengeFor(ctx) returns SecurityKeyChallenge; answer it in verify, check, disable, recovery-codes or reauthenticate with {"method":"security_key","securityKey":{ceremony, credential}} SignedInOrPending 30/minute per IP
POST /auth/twofactor/recovery-codes RegenerateRecoveryCodes(ctx, code), or RegenerateRecoveryCodesWith(ctx, CodeRequest{Code, Password}), returns RecoveryCodes{Codes} FreshSignIn lockout; 30/minute per IP
POST /auth/twofactor/verify with {"code":"…","disableSession":true} VerifyWith(ctx, CodeRequest{Code, DisableSession: true}) PendingSecondFactor as verify; the code is spent and the session stays pending
POST /auth/twofactor/check CheckCode(ctx, code) SignedIn lockout; 30/minute per IP
POST /auth/twofactor/reauthenticate Reauthenticate(ctx, ReauthenticateRequest{Code, Ticket}) returns auth.Reauthentication: the second step of a re-authentication, which makes the current session fresh again SignedIn lockout; 30/minute per IP; 10 per 15 minutes per signed-in user
POST /auth/twofactor/totp-uri TOTPURI(ctx, PasswordRequest{Password}) returns TOTPURIResult{URL} (JSON totpURI) FreshSignIn, plus the password of a user who has one 30/minute per IP, and the password plugin's own rule; exists only with ReadableTOTPURI
POST /auth/twofactor/recovery-codes/view ViewRecoveryCodes(ctx, PasswordRequest{Password}) returns RecoveryCodes{Codes} FreshSignIn, plus the password under RequirePassword 30/minute per IP; exists only with StoreEncrypted
POST /auth/twofactor/verify with {"code":"…","trustDevice":true} VerifyWith(ctx, CodeRequest{Code, TrustDevice}) PendingSecondFactor as verify; ErrTrustDisabled without TrustDevices
GET /auth/twofactor/devices Devices(ctx) returns []TrustedDevice{ID, UserAgent, CreatedAt, ExpiresAt, LastUsedAt, Current} SignedIn none; exists only with TrustDevices
POST /auth/twofactor/devices/forget ForgetDevice(ctx, ForgetDeviceRequest{ID} or {All: true}) FreshSignIn none; exists only with TrustDevices
(Go only) Reset(ctx, userID) turns the user's factor off for support: deletes every factor (further code factors and security keys too), recovery codes and trusted devices, clears two_factor_enabled; sessions stay none: put it behind the app's staff check none
(Go only) Unlock(ctx, userID) lifts a lockout early and clears the wrong-code count none: put it behind the app's staff check none
(Go only) DisableFor(ctx, userID) pauses the factor: sign-in stops asking, the factor, recovery codes and trusted devices stay; sessions stay none: put it behind the app's staff check, or use the admin plugin's disable-two-factor route none
(Go only) EnableFor(ctx, userID) switches a paused factor back on and ends every session of the user; a factor already on is left alone none: as DisableFor; the admin route is enable-two-factor none
(Go only) FactorOf(ctx, userID) returns (method, enabled, err): the confirmed factor's method ("" for none) and whether sign-in asks for it none: the admin route is two-factor none
(Go only) ImportTOTP(ctx, userID, secretBase32) enrols a confirmed TOTP factor with a secret from another system; no recovery codes are made none: call it from an import none
(Go only) SecondFactorRequired(ctx, userID) none none
  • Enroll returns the secret as base32 text and an otpauth:// URL for a QR code. {"issuer":"Beta"} names the app in this user's authenticator instead of Issuer (better-auth's per-call issuer): at most MaxIssuerLength (64) bytes, no colon (it separates issuer and account in the label) and no control character, or ErrIssuerInvalid; it is stored with the factor so totp-uri shows the same name. It replaces an unconfirmed enrollment, and fails with ErrAlreadyEnabled when a factor is on. With {"method":"delivered"} (MethodDelivered, needs DeliveredCodes) it stores no secret and mails or texts the first code to the user's verified identity (primary first, the first kind whose plugin can deliver); a user with no verified channel is ErrNoChannel, and a method the plugin was not built with is ErrMethodInvalid.

  • Send mails or texts a fresh code to a user whose confirmed factor is MethodDelivered, replacing the open one: at the sign-in step on the pending session, or on a signed-in session before disable or recovery-codes. A TOTP user is ErrNotCodeMethod; a locked factor is ErrLocked. The message carries PurposeCode with a default subject and text the identifier plugins use unless the app's templates handle the purpose.

  • Confirm checks the first code, turns the factor on, returns the recovery codes (shown only this once, formatted XXXX-XXXX-XXXX-XXXX), and signs out every other session.

  • Disable and verify accept a current code (TOTP, or the delivered one for MethodDelivered) or an unused recovery code. Spaces around a code, dashes inside it and case are ignored. A delivered code is single use and expires after CodeTTL; its wrong guesses count towards the same lockout as TOTP codes.

  • Verify promotes the pending session to a full one with a new token, so a token captured before the second factor is worthless after it. With disableSession (better-auth's parameter on verify-backup-code, here on every code) it checks and spends the code and leaves the session pending; trustDevice with it is ErrTrustNeedsSession.

  • Check is the step-up for a signed-in user (better-auth's verify on a signed-in session): it checks a current or recovery code and changes no session. Unlike better-auth's re-verify, its wrong codes count towards the lockout, like every other code check here.

  • Totp-uri checks the password first (wrong or missing is the password plugin's password.invalid_credentials), then answers the URI of the user's TOTP factor, confirmed or still in setup, with the issuer they enrolled with. A delivered-code factor is ErrNotTOTP; no factor is ErrNotEnrolled.

  • View-recovery-codes answers the user's unused codes as they were handed out, in the order they were handed out (the seq column; codes made before migration 0005_two_factor_recovery_seq hold 0 and come back in id order, which is stable but not the original order). A code made before StoreEncrypted was turned on has no sealed copy, so the whole answer is ErrRecoveryCodesHidden until the user makes a new set. A used code's sealed copy is cleared when it is spent. A pending session lasts 10 minutes (auth.PendingSessionTTL); after that the user signs in again.

  • Trusted devices (TrustDevices): the row stores the token's hash with the user id, so a token minted for one account skips nothing on another; the transport keeps the token apart from the session, so it survives sign-out. Forgetting the device the request came from, and disable, tell the transport to drop the token (an EffectRememberDevice with an empty token). Every trusted device is forgotten when the factor is disabled and when the user's password changes or is reset (CredentialChanged), because a device trusted before the response to a stolen account must not outlive it. auth sweep deletes expired rows.

  • Recovery-codes replaces the user's recovery codes with a new set, shown once, after a current app code or an unused recovery code; the old set stops working.

  • Another user's factor: FactorOf, DisableFor, EnableFor and Reset are the methods the admin plugin finds by method set (admin.TwoFactorManager) to serve GET /auth/admin/two-factor and POST /auth/admin/disable-two-factor, /enable-two-factor, /reset-two-factor behind its Authorizer; neither package imports the other. A paused factor (DisableFor) is off as far as its user can tell: status answers enabled: false, sign-in does not ask, and enroll replaces it (forgetting its trusted devices) instead of answering ErrAlreadyEnabled; disable, check and recovery-codes still accept its codes.

Lockout: MaxFailures (5) wrong codes, TOTP or recovery, lock the factor for LockDuration (15 minutes), from any number of IPs; Lockout changes both. The per-IP rules only bound how fast one address can reach the database; the lockout is what stops guessing.

Tables and columns

Table Columns Notes
auth_two_factor (TableNameFactor, alias tf) user_id (primary key; deleted with the user by a.DeleteUser, no foreign key), secret_sealed (empty for MethodDelivered), confirmed_at (null until confirmed), last_step, failed_attempts, locked_until, created_at, method (totp or delivered; added by migration 0002_two_factor_method with totp as the default, so rows from before it are authenticator-app factors), issuer (the per-enrolment issuer, empty for the plugin's; migration 0004_two_factor_issuer_sealed_recovery) One factor per user.
auth_two_factor_device (TableNameDevice, alias tfd) id (primary key), user_id (deleted with the user by a.DeleteUser; no foreign key), token_hash (unique), user_agent, created_at, expires_at, last_used_at One row per trusted device; index auth_two_factor_device_user_idx on user_id. Created by migration 0003_two_factor_device whether or not TrustDevices is on.
auth_two_factor_recovery (TableNameRecovery, alias tfr) id (primary key), user_id (deleted with the user by a.DeleteUser; no foreign key), code_hash (SHA-256, unique), used_at, code_sealed (the sealed code under StoreEncrypted, empty otherwise and once used; migration 0004_two_factor_issuer_sealed_recovery), seq (the code's place in its set, 0 first, so view-recovery-codes keeps the issue order; migration 0005_two_factor_recovery_seq, 0 for older rows) Sign-in finds a code by its hash in both storage modes.
auth_two_factor_method (TableNameMethod, alias tfm) user_id and method (the primary key: one row per code method; deleted with the user by a.DeleteUser), secret_sealed, issuer, last_step, confirmed_at, created_at A user's further code factors beside the first, which stays in auth_two_factor with the lockout every factor shares. A user enrolled before factors could be combined has no row here.
auth_user (column added) two_factor_enabled boolean, default FALSE (ColEnabled) Read it with Bindings(&TwoFactorColumns{}) or the Enabled column.

Migrations 0001_two_factor to 0006_two_factor_method_table, owner twofactor. Every table can be renamed with auth.RenameTable(twofactor.TableFactor, "…") or auth.WithTablePrefix; the column names are fixed, because the typed columns (FactorUserID, RecoveryHash, …) are what apps read them by. None of these columns are in the core's stable-column contract (stability_test.go covers auth_user's own columns, auth_identity and auth_session), so treat them as internal.

Errors

Variable Code HTTP When
ErrCodeInvalid twofactor.code_invalid 400 Wrong, expired, replayed or already-used code.
ErrLocked twofactor.locked 429 Too many wrong codes; wait LockDuration.
ErrAlreadyEnabled twofactor.already_enabled 409 Enroll or confirm while a factor is on.
ErrFactorNotEnrolled twofactor.factor_not_enrolled 400 The request names a method the user does not have; not counted as a wrong code.
ErrFactorNotFound twofactor.factor_not_found 404 remove-factor named a key or method that is not the user's.
ErrProofRequired twofactor.proof_required 400 Adding a factor while one is on, without a current factor in proof.
ErrSecurityKeyInvalid twofactor.security_key_invalid 401 The assertion did not verify (wrong, replayed, another user's or removed key, spent ceremony); counted as a wrong code.
ErrNotEnrolled twofactor.not_enrolled 409 Confirm before enroll; disable, verify, send or recovery-codes without a confirmed factor.
ErrSecretInvalid twofactor.secret_invalid 400 ImportTOTP with a secret that is not base32 or decodes to fewer than MinImportSecretBytes (10 bytes, 16 characters).
ErrMethodInvalid twofactor.method_invalid 400 Enroll named a method that is unknown or switched off (MethodDelivered without DeliveredCodes, MethodTOTP with WithoutTOTP).
ErrNoChannel twofactor.no_channel 409 Enroll with MethodDelivered for a user with no verified email address or phone number whose plugin can deliver.
ErrNotCodeMethod twofactor.not_code_method 409 Send for a user whose factor is an authenticator app.
ErrTrustDisabled twofactor.trust_disabled 400 Verify asked to trust the device, but the plugin was built without TrustDevices. Checked before the code, so it is not spent.
ErrDeviceNotFound twofactor.device_not_found 404 Forget with an id that is not one of the user's devices.
ErrIssuerInvalid twofactor.issuer_invalid 400 Enroll with an issuer longer than MaxIssuerLength, or holding a colon or a control character.
ErrTrustNeedsSession twofactor.trust_needs_session 400 Verify with both trustDevice and disableSession. Checked before the code, so it is not spent.
ErrNotTOTP twofactor.not_totp 409 Totp-uri for a factor that is not an authenticator app.
ErrRecoveryCodesHidden twofactor.recovery_codes_hidden 409 View-recovery-codes while an unused code has no sealed copy (made under StoreHashed).
ErrSecretUnreadable twofactor.secret_unreadable 503 A stored secret does not open: the TOTP secret on verify, check, disable, recovery-codes or totp-uri, or a sealed recovery code on view-recovery-codes. The key was dropped or changed, or a Sealer's key service is down. Never counted as a wrong code, so it does not lock the factor; recovery codes still pass, because they are checked by hash.

A secret that does not open is logged at error level (twofactor: a sealed value does not open, with what it was and the user id) and stays wrapped in the error, so a Go caller can errors.Is the cause (auth.ErrSealedCorrupt when the key changed, the Sealer's own error otherwise); the client sees only twofactor.secret_unreadable. The fix is the operator's: restore the previous key to the ring, or reset the user's factor.

Security notes

Property Pinned by
A sign-in with a factor on is a pending session that signs nobody in and reaches no signed-in operation; verify issues a new token and removes the pending one. TestTwoFactor_EnrollSignInVerifyRecover
Unlock lets a locked user in with the right code before the lock ends; Reset turns the factor off (no pending session, no recovery codes, two_factor_enabled false) and enrolment starts over; both are ErrNotEnrolled for a user with no factor integration/TestR23_TwoFactorUnlockAndReset
DisableFor pauses without touching the factor, codes or sessions, and the user sees it off; EnableFor ends the sessions and the kept codes work again; a paused factor can be replaced by enroll, one that is on cannot; both need a confirmed factor TestBatch1_AdminTwoFactor, TestBatch1_PausedFactorCanBeReplaced, TestBatch1_AdminTwoFactorFaults (every database failure reaches the caller)
A secret that will not open answers twofactor.secret_unreadable (503) with the cause wrapped, never the cause's text, logs it, counts no failure, and leaves recovery codes working TestBatch1_VerifyWithAnUnreadableSecret
View-recovery-codes keeps the order the codes were handed out in, after one is used and after a new set TestBatch1_ViewRecoveryCodesInIssueOrder
A code accepted once cannot be used again, even within its 30 seconds: acceptance moves last_step forward in the same UPDATE that checks it. TestTwoFactor_EnrollSignInVerifyRecover
Recovery codes work once, dashes or not, and are stored only as hashes unless the app chose StoreEncrypted. TestTwoFactor_EnrollSignInVerifyRecover
Wrong codes lock the factor, even for the right code, and the lock covers recovery codes too. Failures are counted after the transaction, so a failed request's rollback does not undo the count. TestTwoFactor_EnrollSignInVerifyRecover, TestFactors_TwoFactorLockCoversRecoveryCodes, TestFactors_TwoFactorFailureCountErrorIsLogged
Enroll cannot replace a confirmed factor, and disable needs a code. TestFactors_TwoFactorStateRefusals, TestFactors_TwoFactorDisable
Secrets are sealed with AES-256-GCM bound to the user id; with a lost key, recovery codes still sign in. TestFactors_TwoFactorLostKey
Codes match the RFC 6238 test vectors. TestCode_RFC6238Vector
A trusted device skips the factor only for the user it was minted for, only until its fixed age, never after disable, a password change or reset, or forget; the token is stored hashed; trust is refused before the code is spent when the feature is off. TestFactors_TwoFactorTrustedDevices, TestFactors_TwoFactorTrustedDevicesEnd, TestFactors_TwoFactorTrustedDeviceFaults, TestFactors_TwoFactorTrustedDeviceHTTP
A delivered code is single use, goes only to a verified identity, is refused by the same lockout as TOTP codes (which also stops send), and the method exists only when the app turned it on. TestFactors_TwoFactorDeliveredCodes, TestFactors_TwoFactorDeliveredCodeRefusals, TestOperations_SendOnlyWithDeliveredCodes, TestFactors_TwoFactorDeliveredCodeFaults
A per-enrolment issuer is validated, stored and shown again by totp-uri; skip-verification turns the factor on with the recovery codes and signs out other sessions; totp-uri needs the password of a user who has one and a fresh session, and exists only with its option; verify with disableSession spends the code and issues nothing; check locks like verify; encrypted recovery codes view back, drop out when used and refuse a set made under hashed storage; RequirePassword asks before the code is spent. TestTwoFactorParity_IssuerPerEnrolment, TestTwoFactorParity_SkipVerificationOnEnable, TestTwoFactorParity_TOTPURI, TestTwoFactorParity_TOTPURIOverHTTP, TestTwoFactorParity_DisableSessionAndCheck, TestTwoFactorParity_ViewRecoveryCodes, TestTwoFactorParity_RequirePassword, and the …Faults sweeps beside them
A sealed recovery code opens only for its user and only with its own key, never the TOTP key. TestRecoveryStorage_Validate
The default asks after a password sign-in by email and by username, after the expired-password change and after a direct CreateSession, and not after OAuth, SSO or a magic link; AskEverySignIn asks after every method; a trusted device skips both; every CredentialSignIns id is a real operation. TestDefaultPolicy_AsksAfterCredentialSignInsOnly, TestTwoFactorDefault_AsksAfterEveryCredentialSignIn, TestTwoFactorDefault_SkipsAfterSSO, TestTwoFactorPolicy_DefaultSkipsAfterOAuth, TestUI_TwoFactorThroughThePages
WithSealer seals TOTP secrets and encrypted recovery codes, auth.WithSealer does when the plugin has none, and WithEncryptionKey wins over both; a Sealer that cannot seal writes no factor and one that cannot open fails the check; encrypted recovery codes work with a Sealer and no app secret; a secret sealed the way the plugin sealed before it took a Sealer still opens. TestTwoFactorSealer_ChoosesTheSealer, TestTwoFactorSealer_FailsClosed, TestTwoFactorSealer_RecoveryCodesWithoutSecret, TestTwoFactorSealer_ReadsRowsSealedBeforeTheSeam
The AccountLabel callback receives the core and can read the user's identities; an empty label falls back to the name, then the id. TestTwoFactor_EnrollSignInVerifyRecover, TestAccountLabel_FallsBackToNameThenID

Limits: under a burst of parallel wrong guesses the lock can land a guess or two late (the count is atomic, the lock decision reads the count loaded before the guess). There is no rate rule on the routes beyond the lockout.

Example

Documentation

Overview

Package twofactor adds TOTP second factors (authenticator apps) with single-use recovery codes.

It uses only core mechanisms any plugin has: a column on auth_user (two_factor_enabled, so "is it on" costs no join), tables of its own, the SecondFactor interface that makes sign-in sessions pending, and a PendingSecondFactor operation that promotes the session. No sign-in plugin knows it exists.

Index

Constants

View Source
const (
	// EndpointDevices lists the signed-in user's trusted devices.
	EndpointDevices auth.EndpointID = "twofactor.devices"
	// EndpointForgetDevice revokes one trusted device, or all of them.
	EndpointForgetDevice auth.EndpointID = "twofactor.forget-device"
)

Operation ids and paths of the device operations.

View Source
const (
	//ds:def id=tablenamedevice-x4m9kq7v owner=@khanakia stability=stable
	TableNameDevice  = "auth_two_factor_device"
	TableAliasDevice = "tfd"

	ColDeviceID        = "id"
	ColDeviceUser      = "user_id"
	ColDeviceTokenHash = "token_hash"
	ColDeviceUserAgent = "user_agent"
	ColDeviceCreatedAt = "created_at"
	ColDeviceExpiresAt = "expires_at"
	ColDeviceLastUsed  = "last_used_at"
)

The device table: one row per trusted device, looked up by token hash at sign-in and by user for the list.

View Source
const (
	// DefaultTrustedDeviceAge is how long a trusted device skips the second
	// factor: thirty days, the figure users expect from "remember this
	// browser", and the age is fixed at trust time, never renewed by use,
	// so a stolen cookie has a known end.
	//ds:def id=defaulttrusteddeviceage-m2k7xn4q owner=@khanakia stability=stable
	DefaultTrustedDeviceAge = 30 * 24 * time.Hour
	// MaxTrustedDeviceAge bounds TrustDevices: a year of skipping the second
	// factor is no longer two-factor sign-in.
	//ds:def id=maxtrusteddeviceage-8gw3rz6p owner=@khanakia stability=stable
	MaxTrustedDeviceAge = 365 * 24 * time.Hour
)

Trusted-device lifetimes.

View Source
const (
	// EndpointSecurityKeyChallenge starts the WebAuthn assertion a pending
	// sign-in (or a signed-in step-up) answers with a security key.
	EndpointSecurityKeyChallenge auth.EndpointID = "twofactor.security-key-challenge"
	// EndpointSecurityKeyRegisterBegin and EndpointSecurityKeyRegisterFinish
	// add a security key as a second factor.
	EndpointSecurityKeyRegisterBegin  auth.EndpointID = "twofactor.security-key-register-begin"
	EndpointSecurityKeyRegisterFinish auth.EndpointID = "twofactor.security-key-register-finish"
	// EndpointRemoveFactor removes one factor; removing the last turns
	// two-factor sign-in off, as disable does.
	EndpointRemoveFactor auth.EndpointID = "twofactor.remove-factor"
)

Operation ids of the multi-factor operations.

View Source
const (
	//ds:def id=tablenamemethod-8vq3kd7w owner=@khanakia stability=stable
	TableNameMethod  = "auth_two_factor_method"
	TableAliasMethod = "tfm"
)

The table of a user's further code factors, one row per method.

View Source
const (
	// Step is the default RFC 6238 time step.
	//ds:def id=step-xcvm8nc8 owner=@khanakia stability=stable
	Step = 30 * time.Second
	// Digits is the default code length.
	//ds:def id=digits-j3q4e5tz owner=@khanakia stability=stable
	Digits = 6
	// LongDigits is the other code length RFC 6238 allows and authenticator
	// apps accept.
	//ds:def id=longdigits-g9we2d29 owner=@khanakia stability=stable
	LongDigits = 8
	// Skew is how many steps before and after now are accepted, for a phone
	// whose clock is a little off.
	//ds:def id=skew-v9xvtnr8 owner=@khanakia stability=stable
	Skew = 1
)

TOTP parameters. The defaults are the values every authenticator app supports; Period and Digits change them for an app that must, and changing either after users enrolled breaks their apps, because the values are written into the app at enrolment.

View Source
const (
	EndpointEnroll  auth.EndpointID = "twofactor.enroll"
	EndpointConfirm auth.EndpointID = "twofactor.confirm"
	EndpointDisable auth.EndpointID = "twofactor.disable"
	EndpointVerify  auth.EndpointID = "twofactor.verify"
	// EndpointRecoveryCodes replaces the user's recovery codes.
	EndpointRecoveryCodes auth.EndpointID = "twofactor.recovery-codes"
	// EndpointSend mails or texts a one-time code to a user whose factor is
	// MethodDelivered, for the sign-in step and before disable or regenerate.
	EndpointSend auth.EndpointID = "twofactor.send"
	// EndpointStatus tells a client whether the user has a confirmed factor
	// and which method it uses, so the page after a pending sign-in shows
	// the right form (code from the app, or "we sent you a code") without
	// probing send and reading ErrNotCodeMethod.
	EndpointStatus auth.EndpointID = "twofactor.status"
	// EndpointCheck checks a code for a signed-in user without touching the
	// session: a step-up before a sensitive action (better-auth's verify on
	// an already signed-in session). Same lockout and rate rule as verify.
	EndpointCheck auth.EndpointID = "twofactor.check"
	// EndpointReauthenticate is the second step of a re-authentication: a
	// current code or a recovery code plus the ticket the first step
	// (password.reauthenticate, passkey.reauthenticate-finish) answered with,
	// after which the signed-in session is fresh again. Same lockout as
	// verify.
	EndpointReauthenticate auth.EndpointID = "twofactor.reauthenticate"
	// EndpointTOTPURI re-reads the otpauth:// URI of the user's
	// authenticator-app factor (better-auth's get-totp-uri). It exists only
	// with ReadableTOTPURI.
	EndpointTOTPURI auth.EndpointID = "twofactor.totp-uri"
	// EndpointViewRecoveryCodes shows the user's unused recovery codes again
	// (better-auth's view-backup-codes). It exists only with
	// RecoveryCodeStorage(StoreEncrypted), since hashed codes cannot be shown.
	EndpointViewRecoveryCodes auth.EndpointID = "twofactor.view-recovery-codes"
)

Operation ids, the permanent names hooks, rate rules and transports address the plugin's operations by.

View Source
const (
	//ds:def id=tablenamefactor-qk9j7b3v owner=@khanakia stability=stable
	TableNameFactor  = "auth_two_factor"
	TableAliasFactor = "tf"
	//ds:def id=tablenamerecovery-74y7xq39 owner=@khanakia stability=stable
	TableNameRecovery = "auth_two_factor_recovery"
	TableAliasRecover = "tfr"

	ColUserID      = "user_id"
	ColSecret      = "secret_sealed"
	ColConfirmedAt = "confirmed_at"
	ColLastStep    = "last_step"
	ColFailures    = "failed_attempts"
	ColLockedUntil = "locked_until"
	ColCreatedAt   = "created_at"
	// ColMethod is the factor's Method; "totp" for every row made before the
	// column existed, which is what its default says.
	ColMethod = "method"
	// ColIssuer is the issuer the user enrolled with when EnrollRequest named
	// one (better-auth's per-call issuer); "" means the plugin's Issuer, and
	// is what every row made before the column existed holds.
	ColIssuer = "issuer"

	ColRecoveryID   = "id"
	ColRecoveryUser = "user_id"
	ColRecoveryHash = "code_hash"
	ColRecoveryUsed = "used_at"
	// ColRecoverySealed holds the code itself, sealed, under
	// RecoveryCodeStorage(StoreEncrypted) so view-recovery-codes can show it;
	// "" under the default hashed storage and once the code is used.
	ColRecoverySealed = "code_sealed"
	// ColRecoverySeq is a code's place in the set as it was handed out
	// (0, 1, 2, …), so view-recovery-codes shows the set in the order the
	// user printed it. The id is random and cannot order it. Every row made
	// before the column existed holds 0, and those sort by id: a stable
	// order, though not the original one.
	ColRecoverySeq = "seq"

	// ColEnabled is the column this plugin adds to auth_user.
	//ds:def id=colenabled-n2n83a62 owner=@khanakia stability=stable
	ColEnabled = "two_factor_enabled"
)

Tables and columns. auth_two_factor holds one factor per user, created unconfirmed by enroll and confirmed by confirm; auth_two_factor_recovery holds the hashes of the user's single-use recovery codes.

View Source
const (
	// DefaultIssuer is the app name authenticator apps show without Issuer.
	//ds:def id=defaultissuer-enheudet owner=@khanakia stability=stable
	DefaultIssuer = "App"
	// DefaultRecoveryCodes is how many single-use codes confirm hands out:
	// enough for years of lost phones, few enough to print on one card.
	//ds:def id=defaultrecoverycodes-qp2yzv4v owner=@khanakia stability=stable
	DefaultRecoveryCodes = 10
	// MaxFailures wrong codes lock the factor for LockDuration. A six-digit
	// code accepts 3 of a million values per window, so the lock is what
	// makes guessing hopeless, from any number of IPs.
	//ds:def id=maxfailures-dyu8kjqs owner=@khanakia stability=stable
	MaxFailures = 5
	//ds:def id=lockduration-mh4x5jpc owner=@khanakia stability=stable
	LockDuration = 15 * time.Minute
	// DefaultRecoveryCodeLength is a recovery code's length in base32
	// characters: 16 characters carry 80 bits, far beyond what the lockout
	// lets anyone guess, and read as four groups of four.
	//ds:def id=defaultrecoverycodelength-trh532bc owner=@khanakia stability=stable
	DefaultRecoveryCodeLength = 16
	// MinRecoveryCodeLength is the shortest RecoveryCodeLength accepted: 8
	// base32 characters are 40 bits, which the lockout still makes hopeless
	// to guess but a shorter code would not be.
	//ds:def id=minrecoverycodelength-k2frj7m4 owner=@khanakia stability=stable
	MinRecoveryCodeLength = 8

	// DefaultCodeTTL is how long a delivered code works: long enough for a
	// slow mailbox, short enough that an intercepted message is stale.
	//ds:def id=defaultcodettl-2wq9hz5r owner=@khanakia stability=stable
	DefaultCodeTTL = 5 * time.Minute

	// MaxIssuerLength bounds a per-enrolment EnrollRequest.Issuer, in bytes:
	// long enough for any product name, short enough that a client cannot
	// store an essay in the factor row or the user's authenticator app.
	//ds:def id=maxissuerlength-r7u2kd9w owner=@khanakia stability=stable
	MaxIssuerLength = 64
)

Defaults and limits.

View Source
const KeyLabelRecoveryCodes = "twofactor-recovery-codes"

KeyLabelRecoveryCodes is the label the sealing key of StoreEncrypted is derived under, from auth.WithSecret. Fixed, so a rotation of the secret (current first, previous after) keeps every sealed code opening; distinct from the TOTP secrets' auth.KeyLabelTwoFactor, so the two never share a key.

View Source
const MinImportSecretBytes = 10

MinImportSecretBytes is the shortest TOTP secret ImportTOTP accepts: 80 bits, the 16-character base32 secrets the oldest authenticator deployments issued. RFC 4226 recommends 160 bits (what Enroll makes), but an import that refused shorter real secrets would lock those users out of a factor they already use.

View Source
const PluginName = "twofactor"

PluginName is the plugin's permanent identity. It owns the plugin's rows in the migration ledger and its tables and columns, so renaming it would make the next migrate see a new plugin; it never changes.

View Source
const PurposeCode auth.Purpose = "twofactor.code"

PurposeCode is the message purpose of a delivered second-factor code, and the purpose of its core one-time code; the subject of the code is the user id. Delivering plugins word it with the message's DefaultSubject and DefaultText unless the app's templates handle the purpose.

Variables

Typed columns of the device table, for an app that reads it.

View Source
var (
	// ErrTrustDisabled: a verify asked to trust the device, but the plugin
	// was built without TrustDevices. Refused rather than ignored, so a
	// client that offers the checkbox learns the server will not honour it.
	ErrTrustDisabled = auth.NewError("twofactor.trust_disabled", http.StatusBadRequest, "Remembering this device is not available.")
	// ErrDeviceNotFound: forget named a device that is not the user's.
	ErrDeviceNotFound = auth.NewError("twofactor.device_not_found", http.StatusNotFound, "Not found.")
	// ErrBadTrustAge: TrustDevices was given no duration, or more than
	// MaxTrustedDeviceAge.
	ErrBadTrustAge = errors.New("twofactor: TrustDevices needs a positive duration of at most MaxTrustedDeviceAge")
)

Errors of the device operations and option.

View Source
var (
	TableMethod = sqlb.Table{Name: TableNameMethod, Alias: TableAliasMethod}

	MethodUserID      = sqlb.Col[string](TableMethod, ColUserID)
	MethodKind        = sqlb.Col[string](TableMethod, ColMethod)
	MethodSecret      = sqlb.Col[string](TableMethod, ColSecret)
	MethodIssuer      = sqlb.Col[string](TableMethod, ColIssuer)
	MethodLastStep    = sqlb.Col[int64](TableMethod, ColLastStep)
	MethodConfirmedAt = sqlb.Col[*time.Time](TableMethod, ColConfirmedAt)
	MethodCreatedAt   = sqlb.Col[time.Time](TableMethod, ColCreatedAt)
)

Typed columns of auth_two_factor_method. The column names are the primary row's (ColUserID, ColMethod, ColSecret, ColIssuer, ColLastStep, ColConfirmedAt, ColCreatedAt): a further factor carries the same facts, without the lockout, which stays on the primary row.

View Source
var (
	// ErrFactorNotEnrolled: the request names a factor the user does not
	// have (a method never enrolled, or one removed). Not counted as a wrong
	// code: no secret was tried.
	ErrFactorNotEnrolled = auth.NewError("twofactor.factor_not_enrolled", http.StatusBadRequest, "This two-factor method is not set up for your account.")
	// ErrFactorNotFound: remove-factor named a security key id that is not
	// one of the user's keys, or a method the user does not have.
	ErrFactorNotFound = auth.NewError("twofactor.factor_not_found", http.StatusNotFound, "Not found.")
	// ErrProofRequired: adding or removing a factor while two-factor sign-in
	// is on needs a current factor (a code, a recovery code or a security
	// key), or a stolen fresh session could plant its own factor.
	ErrProofRequired = auth.NewError("twofactor.proof_required", http.StatusBadRequest, "Confirm with your current two-factor method first.")
	// ErrSecurityKeyInvalid answers an assertion that does not verify: a
	// wrong or replayed signature, a key of another user, a removed key, an
	// expired or spent ceremony. A SecurityKeyStore returns it (wrapped or
	// not) for every refusal; twofactor counts it as a wrong code.
	ErrSecurityKeyInvalid = auth.NewError("twofactor.security_key_invalid", http.StatusUnauthorized, "This security key could not be verified.")
)

Errors of the multi-factor operations.

View Source
var (
	// ErrBadRecoveryStorage: RecoveryCodeStorage got a value that is not one
	// of RecoveryStorageValues.
	ErrBadRecoveryStorage = errors.New("twofactor: RecoveryCodeStorage must be StoreHashed or StoreEncrypted")
	// ErrRecoveryStorageNeedsSecret: StoreEncrypted derives its key from
	// auth.WithSecret, and the app passed none.
	ErrRecoveryStorageNeedsSecret = errors.New("twofactor: RecoveryCodeStorage(StoreEncrypted) needs auth.WithSecret")
	// ErrRecoveryCodesHidden: an unused code of the user has no sealed copy
	// (it was made before StoreEncrypted was turned on), so the set cannot be
	// shown whole; recovery-codes makes a new one that can.
	ErrRecoveryCodesHidden = auth.NewError("twofactor.recovery_codes_hidden", http.StatusConflict, "These recovery codes cannot be shown again. Make a new set.")
)

Errors of recovery-code storage.

View Source
var (
	// ErrNotTOTP: totp-uri was asked for a factor that is not an
	// authenticator app; a delivered-code factor has no URI.
	ErrNotTOTP = auth.NewError("twofactor.not_totp", http.StatusConflict, "Your second factor is not an authenticator app.")
	// ErrTrustNeedsSession: verify was asked to trust the device and to leave
	// the session pending in one call. A device is trusted for a sign-in, so
	// the pair is refused rather than half done.
	ErrTrustNeedsSession = auth.NewError("twofactor.trust_needs_session", http.StatusBadRequest, "A device can only be remembered when signing in.")
)

Errors of the step-up operations.

View Source
var (
	TableFactor   = sqlb.Table{Name: TableNameFactor, Alias: TableAliasFactor}
	TableRecovery = sqlb.Table{Name: TableNameRecovery, Alias: TableAliasRecover}

	FactorUserID      = sqlb.Col[string](TableFactor, ColUserID)
	FactorSecret      = sqlb.Col[string](TableFactor, ColSecret)
	FactorConfirmedAt = sqlb.Col[*time.Time](TableFactor, ColConfirmedAt)
	FactorLastStep    = sqlb.Col[int64](TableFactor, ColLastStep)
	FactorFailures    = sqlb.Col[int](TableFactor, ColFailures)
	FactorLockedUntil = sqlb.Col[*time.Time](TableFactor, ColLockedUntil)
	FactorCreatedAt   = sqlb.Col[time.Time](TableFactor, ColCreatedAt)
	FactorMethod      = sqlb.Col[string](TableFactor, ColMethod)
	FactorIssuer      = sqlb.Col[string](TableFactor, ColIssuer)

	RecoveryID   = sqlb.Col[string](TableRecovery, ColRecoveryID)
	RecoveryUser = sqlb.Col[string](TableRecovery, ColRecoveryUser)
	RecoveryHash = sqlb.Col[[]byte](TableRecovery, ColRecoveryHash)
	RecoveryUsed = sqlb.Col[*time.Time](TableRecovery, ColRecoveryUsed)
	// RecoverySealed is the sealed code under StoreEncrypted, "" otherwise.
	RecoverySealed = sqlb.Col[string](TableRecovery, ColRecoverySealed)
	// RecoverySeq is the code's place in its set; see ColRecoverySeq.
	RecoverySeq = sqlb.Col[int](TableRecovery, ColRecoverySeq)

	// Enabled is two_factor_enabled on auth_user, for a composite user.
	Enabled = sqlb.Col[bool](auth.TableUser, ColEnabled)
)

Typed columns, for an app that reads the tables (reads are open; only the plugin writes them).

View Source
var (
	ErrCodeInvalid    = auth.NewError("twofactor.code_invalid", http.StatusBadRequest, "This code is not right.")
	ErrLocked         = auth.NewError("twofactor.locked", http.StatusTooManyRequests, "Too many wrong codes. Wait and try again.")
	ErrAlreadyEnabled = auth.NewError("twofactor.already_enabled", http.StatusConflict, "Two-factor sign-in is already on.")
	// ErrSecretInvalid refuses ImportTOTP with a secret that is not base32
	// or decodes to fewer than MinImportSecretBytes.
	ErrSecretInvalid = auth.NewError("twofactor.secret_invalid", http.StatusBadRequest, "The TOTP secret is not valid.")
	ErrNotEnrolled   = auth.NewError("twofactor.not_enrolled", http.StatusConflict, "Start two-factor setup first.")
	// ErrMethodInvalid: Enroll named a method that is not one of MethodValues,
	// or one the plugin was not built with (MethodDelivered without
	// DeliveredCodes(), MethodTOTP with WithoutTOTP()).
	ErrMethodInvalid = auth.NewError("twofactor.method_invalid", http.StatusBadRequest, "Choose an available two-factor method.")
	// ErrNoChannel: MethodDelivered needs a verified email address or phone number
	// whose plugin can deliver, and the user has none.
	ErrNoChannel = auth.NewError("twofactor.no_channel", http.StatusConflict, "Verify an email address or phone number first.")
	// ErrNotCodeMethod: send was called for a user whose factor is an
	// authenticator app; there is nothing to send.
	ErrNotCodeMethod   = auth.NewError("twofactor.not_code_method", http.StatusConflict, "Your authenticator app has the code.")
	ErrNoEncryptionKey = errors.New("twofactor: pass twofactor.WithEncryptionKey with a 32-byte key, or auth.WithSecret")
	// ErrNoMethod: WithoutTOTP() without DeliveredCodes() leaves no way to
	// enrol.
	ErrNoMethod       = errors.New("twofactor: WithoutTOTP needs DeliveredCodes, or nobody can enrol")
	ErrBadCodeTTL     = errors.New("twofactor: CodeTTL must be positive")
	ErrBadDigits      = errors.New("twofactor: Digits must be 6 or 8")
	ErrBadPeriod      = errors.New("twofactor: Period must be a positive number of whole seconds")
	ErrBadRecoveryLen = errors.New("twofactor: RecoveryCodeLength must be at least MinRecoveryCodeLength")
	ErrBadLockout     = errors.New("twofactor: Lockout needs at least one allowed failure and a positive duration")
	// ErrBadRecoveryCodes is returned by confirm or regenerate when a
	// RecoveryCodeGenerator handed back no codes, an empty one or a duplicate.
	ErrBadRecoveryCodes = errors.New("twofactor: RecoveryCodeGenerator returned no codes, an empty code or a duplicate")
	// ErrIssuerInvalid refuses an EnrollRequest.Issuer longer than
	// MaxIssuerLength, or holding a colon (it separates the issuer from the
	// account in the otpauth label, so an app would split the name wrongly)
	// or a control character.
	ErrIssuerInvalid = auth.NewError("twofactor.issuer_invalid", http.StatusBadRequest, "The issuer name is not valid.")
	// ErrSecretUnreadable answers an operation whose stored secret will not
	// open: a TOTP secret on verify, check, disable, recovery-codes or
	// totp-uri, or a sealed recovery code on view-recovery-codes. The key it
	// was sealed with was dropped from the ring, the app secret changed
	// without keeping the previous one, or a Sealer's key service is down.
	// That is the server's fault, not the code's, so it is never counted as a
	// wrong code and never locks the factor; a recovery code still passes,
	// because it is checked by its hash, which the message says. The cause is
	// logged for the operator and stays wrapped for errors.Is
	// (auth.ErrSealedCorrupt, a KMS error); the client sees only this code.
	// Every operation that meets it runs for a caller who already proved the
	// first factor, so it tells nobody whether an account exists.
	ErrSecretUnreadable = auth.NewError("twofactor.secret_unreadable", http.StatusServiceUnavailable, "Two-factor codes cannot be checked right now. A recovery code still works; otherwise contact support.")
)

Errors. The *auth.Error values are what a client sees; ErrNoEncryptionKey is a configuration mistake auth.New refuses at start-up.

View Source
var CredentialSignIns = []auth.EndpointID{"password.sign-in", "password.change-expired", "password.reauthenticate"}

CredentialSignIns are the operations after which DefaultPolicy asks for the factor: the sign-ins that prove a password. They are this library's equivalents of the routes better-auth's twoFactor plugin hooks (v1.7.7: /sign-in/email, /sign-in/username and /sign-in/phone-number, all password sign-ins; here one operation takes any identifier kind), plus the expired-password change, which signs in with a password too and would otherwise skip the factor, and the password re-authentication of a signed-in session (auth.Reauthenticate), which asks what a password sign-in asks, so a stolen session plus a leaked password cannot reach the fresh window without the code. The ids are strings because the password plugin is a module of its own; an integration test fails on an id no operation has.

MethodValues is the canonical iteration order over methods.

View Source
var RecoveryStorageValues = []RecoveryStorage{StoreHashed, StoreEncrypted}

RecoveryStorageValues is the canonical iteration order over storage modes.

Functions

func AskEverySignIn

func AskEverySignIn(_ context.Context, in PolicyRequest) (bool, error)

AskEverySignIn is the policy that asks for the factor after every sign-in, whatever the method (OAuth, SSO, magic link and one-time codes included), except from a trusted device: WithPolicy(AskEverySignIn) for an app whose second factor must not depend on how the user signed in. It was the default before DefaultPolicy followed better-auth.

func Bindings

func Bindings(c *TwoFactorColumns) []sqlb.Binding

Bindings binds c's fields to their auth_user columns, for auth's LoadUserColumns or a hand-written select of the user.

func DefaultPolicy

func DefaultPolicy(_ context.Context, in PolicyRequest) (bool, error)

DefaultPolicy is the policy without WithPolicy, better-auth's rule: ask for the factor after a password sign-in (CredentialSignIns), and after a sign-in that names no operation (a direct CreateSession from Go, which cannot say how the user proved who they are), except from a trusted device. It also asks after a sign-in whose method reports it proved one factor (SignInAttempt.SingleFactor): a passkey whose authenticator did not verify the user proves possession only. An OAuth, SSO, magic link, one-time-code or user-verified passkey sign-in does not ask: with those methods turned on, a user with a factor can sign in without it. AskEverySignIn asks after every method instead. A custom policy can call DefaultPolicy for the cases it does not decide.

Types

type CodeRequest

type CodeRequest struct {
	Code string `json:"code"`
	// TrustDevice, on verify, asks to skip the second factor on this device
	// for the TrustDevices age. Refused (ErrTrustDisabled) when the plugin
	// was built without TrustDevices, and with DisableSession
	// (ErrTrustNeedsSession); ignored by the other operations.
	TrustDevice bool `json:"trustDevice,omitempty"`
	// DisableSession, on verify, checks the code and leaves the session
	// pending: better-auth's disableSession. The code is spent and counted
	// like any other; nobody is signed in. A signed-in user's step-up check
	// is the check operation (CheckCode) instead.
	DisableSession bool `json:"disableSession,omitempty"`
	// Password is the user's password, on disable and recovery-codes, when
	// the plugin was built with RequirePassword and the user has one;
	// ignored otherwise.
	Password auth.Token `json:"password,omitempty"`
	// Method is the factor the user picked at the challenge: the code
	// method Code is from, or MethodSecurityKey with SecurityKey set. ""
	// means the user's first code factor, which is the only one a user
	// enrolled before factors could be combined has. A method the user does
	// not have is ErrFactorNotEnrolled. A recovery code passes with "" or
	// any code method. On confirm it names the enrolment being confirmed.
	Method Method `json:"method,omitempty"`
	// SecurityKey is the assertion answering security-key-challenge, with
	// Method MethodSecurityKey.
	SecurityKey *SecurityKeyResponse `json:"securityKey,omitempty"`
}

CodeRequest carries a TOTP code or a recovery code. Spaces around it and dashes inside it are ignored, and case does not matter.

type DevicesResult

type DevicesResult struct {
	Devices []TrustedDevice `json:"devices"`
}

DevicesResult is the output of devices, oldest first.

type EnrollRequest

type EnrollRequest struct {
	Method Method `json:"method,omitempty"`
	// Issuer names the app in this user's authenticator app instead of the
	// plugin's Issuer: better-auth's per-call issuer, for one deployment
	// serving several brands. It is stored with the factor, so totp-uri
	// shows the same name later. At most MaxIssuerLength bytes, no colon, no
	// control character (ErrIssuerInvalid); "" means the plugin's Issuer.
	// Ignored for MethodDelivered, which has no authenticator app.
	Issuer string `json:"issuer,omitempty"`
	// Password is the user's password when the plugin was built with
	// RequirePassword and the user has one; ignored otherwise.
	Password auth.Token `json:"password,omitempty"`
	// Proof is a current factor, needed to add a method beside factors that
	// are on (ErrProofRequired without it). Ignored for a first factor.
	Proof *FactorProof `json:"proof,omitempty"`
}

EnrollRequest chooses the method. Empty means MethodTOTP, so a client written before methods existed sends no body and gets what it always got.

type EnrollResult

type EnrollResult struct {
	Method Method `json:"method"`
	Secret string `json:"secret,omitempty"`
	URL    string `json:"url,omitempty"`
	// Enabled is true when SkipVerificationOnEnable turned the factor on at
	// once; RecoveryCodes are then the user's codes, shown this once, and
	// there is no confirm step.
	Enabled       bool     `json:"enabled"`
	RecoveryCodes []string `json:"recoveryCodes,omitempty"`
}

EnrollResult is what the app shows. For MethodTOTP: a QR code (URL) and the text to type (Secret). For MethodDelivered: Secret and URL are empty, and the first code is already on its way to the user's verified identity. Either way the factor stays off until Confirm proves the user receives codes.

type EnrolmentRule

type EnrolmentRule func(ctx context.Context, a *auth.Auth, userID string) (bool, error)

EnrolmentRule decides whether userID must have a second factor before their session is usable: always (func returning true), per role, or per organization, which the rule looks up itself through a so this plugin imports no organization model. An error requires enrolment: a rule that cannot decide fails closed.

type Factor

type Factor struct {
	Method Method `json:"method"`
	// ID names a security key (remove-factor takes it); "" for a code factor.
	ID   string `json:"id,omitempty"`
	Name string `json:"name,omitempty"`
	// Primary marks the factor a code without a method is checked against.
	Primary    bool       `json:"primary,omitempty"`
	CreatedAt  time.Time  `json:"createdAt"`
	LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
}

Factor is one of a user's confirmed second factors, as status lists them.

type FactorProof

type FactorProof struct {
	Method      Method               `json:"method,omitempty"`
	Code        string               `json:"code,omitempty"`
	SecurityKey *SecurityKeyResponse `json:"securityKey,omitempty"`
}

FactorProof is a current second factor of the user, asked for before a factor is added or removed while two-factor sign-in is on: a code (TOTP, delivered or recovery) or a security-key assertion. Method picks the factor the code is from; "" means the user's first code factor.

type ForgetDeviceRequest

type ForgetDeviceRequest struct {
	ID  string `json:"id,omitempty"`
	All bool   `json:"all,omitempty"`
}

ForgetDeviceRequest names one device of the signed-in user, or all of them.

type LabelFunc

type LabelFunc func(ctx context.Context, a *auth.Auth, u auth.User) string

LabelFunc names a user's account on their device. It receives the core, so it can read what the plugin cannot know, such as the user's email address through a.Identities, without the app capturing its *auth.Auth in a closure before auth.New has returned it. An empty result falls back to the default.

type Method

type Method string

Method is how a user's second factor produces codes.

const (
	// MethodTOTP: an authenticator app computes the codes from a shared
	// secret (RFC 6238).
	MethodTOTP Method = "totp"
	// MethodDelivered: the server mails or texts a one-time code to a verified
	// identity of the user at each sign-in. Weaker than TOTP (it trusts the
	// mailbox or the phone network), and the only second factor for users
	// who will not install an app.
	MethodDelivered Method = "delivered"
)

The two methods. MethodTOTP is the default and what Enroll without a method means, so existing clients see no change. MethodDelivered exists only when the plugin was built with DeliveredCodes().

const MethodSecurityKey Method = "security_key"

MethodSecurityKey is a WebAuthn authenticator (a USB or NFC key, or the device's own platform authenticator) used as a second factor. It exists only with WithSecurityKeys. A security key is enrolled through security-key-register-begin and -finish, never through enroll, and is used at the challenge by sending a SecurityKeyResponse instead of a code.

type Option

type Option func(*Plugin)

Option configures the plugin; pass options to New.

func AccountLabel

func AccountLabel(fn LabelFunc) Option

AccountLabel sets the account name an authenticator app shows next to the issuer, such as the user's email address. The plugin does not depend on the email plugin, so it cannot pick one itself; without this option, or when fn returns "", the label is the user's name, or the user id when the name is empty.

func CodeTTL

func CodeTTL(d time.Duration) Option

CodeTTL overrides DefaultCodeTTL, how long a delivered code works. The length of the code is the core's (auth.WithCodeLength).

func DeliveredCodes

func DeliveredCodes() Option

DeliveredCodes lets users enrol MethodDelivered: a one-time code mailed or texted to a verified identity at each sign-in, through the identifier plugins' delivery (email.WithMailer, phone.WithSMS). Off by default, because a code in a mailbox is a weaker factor than an authenticator app; turn it on for users who will not install one. The sign-in step then has a send operation (EndpointSend) before verify.

func Issuer

func Issuer(name string) Option

Issuer names the app in authenticator apps. It is written into the app at enrollment, so changing it later relabels nothing already enrolled.

func Lockout

func Lockout(maxFailures int, d time.Duration) Option

Lockout sets how many wrong codes in a row lock the factor and for how long; the defaults are MaxFailures and LockDuration. A six-digit code accepts 3 of a million values per step, so the lock is what makes guessing hopeless: raise maxFailures with care.

func ReadableTOTPURI

func ReadableTOTPURI() Option

ReadableTOTPURI registers totp-uri: the signed-in user reads the otpauth:// URI of their authenticator-app factor again, to add a second device or redraw a lost QR code (better-auth's get-totp-uri). It needs a fresh sign-in and, for a user with a password, the password, which is better-auth's guard plus the fresh window.

Off by default, because the URI is the secret itself: whoever reads it clones the authenticator, and for a user without a password a stolen fresh session is then enough to defeat the second factor for good, with no trace the user would notice. Without this option a lost QR code during setup is covered by enrolling again.

func RecoveryCodeCount

func RecoveryCodeCount(n int) Option

RecoveryCodeCount overrides DefaultRecoveryCodes. It applies at the next confirm or regenerate; codes already handed out keep working.

func RecoveryCodeGenerator

func RecoveryCodeGenerator(fn func() []string) Option

RecoveryCodeGenerator replaces the random codes with the app's own, for a house format or words. fn returns the codes as the user will see them; they are compared after trimming, upper-casing and removing dashes and spaces, so a code is accepted however the user types it. Only their hashes are stored. Empty or duplicate codes fail the confirm with ErrBadRecoveryCodes.

func RecoveryCodeLength

func RecoveryCodeLength(n int) Option

RecoveryCodeLength overrides DefaultRecoveryCodeLength, in base32 characters; at least MinRecoveryCodeLength. Codes are still shown in groups of four. Applies at the next confirm or regenerate.

func RecoveryCodeStorage

func RecoveryCodeStorage(s RecoveryStorage) Option

RecoveryCodeStorage chooses how recovery codes are kept; the default is StoreHashed. StoreEncrypted registers view-recovery-codes and needs auth.WithSecret (ErrRecoveryStorageNeedsSecret). It applies to codes made from then on: switching to it leaves the current codes viewable only after the user makes a new set, and switching back leaves the sealed copies in place until the codes are used or replaced.

func RequireEnrolment

func RequireEnrolment(rule EnrolmentRule) Option

RequireEnrolment makes a user the rule names, who has no second factor on, sign in to a session with the core's auth.TaskSetupMFA task pending: it reaches only status and the enrolment operations (enroll, confirm, and the security-key registration with WithSecurityKeys), and every other operation answers auth.ErrSessionTaskPending, until a factor is turned on. Then the session is replaced by an ordinary one under a new token, and the old token stops working. Without this option the plugin declares no task and nothing changes. A nil rule is refused (auth.ErrNilOption).

func RequirePassword

func RequirePassword() Option

RequirePassword makes enroll, disable, recovery-codes and view-recovery-codes ask for the password of a user who has one, on top of the fresh sign-in they already need: better-auth's password field on enable, disable and generate-backup-codes. A user without a password (a passkey or social sign-in) passes on the fresh session alone, which is better-auth's allowPasswordless; better-auth's default, refusing such a user outright, is not offered, because it would keep exactly the users with no password from turning a second factor on. Off by default: the fresh session is the proof this library asks for everywhere else.

func SkipVerificationOnEnable

func SkipVerificationOnEnable() Option

SkipVerificationOnEnable turns a factor on at enroll, without the first code confirm asks for: better-auth's skipVerificationOnEnable. Enroll then answers Enabled with the recovery codes (the only time they are shown), and signs out every other session as confirm does. A delivered-code factor sends no first code, like better-auth's method "otp"; the user still needs a verified identity (ErrNoChannel otherwise).

Off by default, and riskier: nothing proves the authenticator app holds the secret, so a user who mis-scanned the QR code is locked out at the next sign-in until they use a recovery code, and a delivered-code user whose mailbox or phone does not receive codes is locked out the same way. Turn it on only where the app shows the recovery codes prominently.

func TrustDevices

func TrustDevices(maxAge time.Duration) Option

TrustDevices lets a user skip the second factor on a device they trusted at verify (CodeRequest.TrustDevice), for maxAge from that moment; 0 means DefaultTrustedDeviceAge. Trust is bound to the user and to the device token the transport keeps in its own cookie, is listed and revocable (Devices, ForgetDevice), ends when the factor is disabled, and is cleared when the user's password changes, because a device trusted before a takeover should not outlive the response to it.

func WithDigits

func WithDigits(n int) Option

WithDigits sets the code length, 6 (Digits) or 8 (LongDigits). It is written into the authenticator app at enrolment, so changing it later breaks every enrolled user's app.

func WithEncryptionKey

func WithEncryptionKey(key []byte) Option

WithEncryptionKey sets the AES-256 key that seals TOTP secrets at rest. Required unless the app set auth.WithSecret, in which case the key is derived from it under auth.KeyLabelTwoFactor and a rotation of the secret keeps every enrolment opening. Losing the key disables every enrolled factor; users then sign in with a recovery code.

func WithPeriod

func WithPeriod(d time.Duration) Option

WithPeriod sets the time step, in whole seconds; the default is Step. Like WithDigits it is written into the app at enrolment, and changing it later breaks enrolled apps.

func WithPolicy

func WithPolicy(fn Policy) Option

WithPolicy replaces DefaultPolicy, for an app whose rule depends on the sign-in: ask after every method (AskEverySignIn), always ask an admin even after OAuth or on a trusted device, or ask only from an unknown network. For example, better-auth's rule for everyone but admins, who are asked after every method:

twofactor.WithPolicy(func(ctx context.Context, in twofactor.PolicyRequest) (bool, error) {
	if isAdmin(ctx, in.UserID) {
		return twofactor.AskEverySignIn(ctx, in)
	}
	return twofactor.DefaultPolicy(ctx, in)
})

A nil policy keeps DefaultPolicy.

func WithSealer

func WithSealer(s auth.Sealer) Option

WithSealer seals TOTP secrets and StoreEncrypted recovery codes with s, for an app whose keys live in a KMS or HSM rather than in process memory. WithEncryptionKey still wins for TOTP secrets; without either the plugin uses auth.WithSealer's Sealer, then keys derived from auth.WithSecret. A nil s is the same as not passing the option. Values already stored stay readable only through a Sealer that opens them: switching from the derived keys to a KMS needs the old values re-enrolled or a Sealer that falls back to auth.RingSealer for them.

func WithSecurityKeys

func WithSecurityKeys(s SecurityKeyStore) Option

WithSecurityKeys offers security keys as a second factor, stored by s (passkey's SecurityKeys; register the passkey plugin too). Methods and status then list MethodSecurityKey, and the security-key operations are registered. A nil s is refused (auth.ErrNilOption).

func WithoutTOTP

func WithoutTOTP() Option

WithoutTOTP removes the authenticator-app method: Enroll with MethodTOTP is ErrMethodInvalid, and a plugin without DeliveredCodes is refused by auth.New (ErrNoMethod). Factors already enrolled with TOTP keep working.

type PasswordChecker

type PasswordChecker interface {
	auth.Plugin
	HasPassword(ctx context.Context, userID string) (bool, error)
	Verify(ctx context.Context, password auth.Token) error
}

PasswordChecker is what the plugin needs from a password plugin to ask for the user's password, as better-auth does: the two methods `*password.Plugin` already has. Found through auth.PluginOf at Init, so this package imports no other module and an app registers nothing extra. Verify runs the password plugin's own operation, with its rate limit and its enumeration-safe timing.

type PasswordRequest

type PasswordRequest struct {
	Password auth.Token `json:"password,omitempty"`
}

PasswordRequest carries the user's password, for totp-uri and view-recovery-codes; ignored for a user without one.

type Plugin

type Plugin struct {
	// contains filtered or unexported fields
}

Plugin is the TOTP second factor. Build it with New and pass it to auth.WithPlugins; any sign-in method's sessions become pending for a user with a confirmed factor.

func New

func New(opts ...Option) *Plugin

New builds the plugin. WithEncryptionKey is required; auth.New refuses the plugin without it.

func (*Plugin) CheckCode

func (p *Plugin) CheckCode(ctx context.Context, code string) error

CheckCode verifies a current code or an unused recovery code for the signed-in user in ctx and changes no session: a step-up before a sensitive action. It is the check operation, so it has verify's rate rule and lockout; a delivered-code user calls Send first.

func (*Plugin) Columns

func (p *Plugin) Columns() []auth.ColumnExtension

Columns adds two_factor_enabled to auth_user, default false, which is what SecondFactorRequired reads.

func (*Plugin) Confirm

func (p *Plugin) Confirm(ctx context.Context, code string) (RecoveryCodes, error)

Confirm checks the first code and turns the factor on. It returns the recovery codes, the only time they are shown, and signs out every other session, since those were created without the factor.

func (*Plugin) CredentialChanged

func (p *Plugin) CredentialChanged(ctx context.Context, userID string) error

CredentialChanged forgets every trusted device of userID. The core calls it when a plugin reports that a credential of the user changed (a password reset or change): a device trusted before the response to a stolen account must not keep skipping the second factor after it.

func (*Plugin) Devices

func (p *Plugin) Devices(ctx context.Context) ([]TrustedDevice, error)

Devices lists the signed-in user's trusted devices, oldest first.

func (*Plugin) Disable

func (p *Plugin) Disable(ctx context.Context, code string) error

Disable turns the factor off; it needs a current code or a recovery code.

func (*Plugin) DisableFor

func (p *Plugin) DisableFor(ctx context.Context, userID string) error

DisableFor pauses userID's second factor: sign-in stops asking for it, and the factor, its recovery codes and trusted devices stay, so EnableFor can switch it back on without the user enrolling again. It is the support desk's answer to "my phone is in for repair": Reset is the answer to "my phone is gone". While paused the user sees two-factor as off in status and may enrol afresh, which replaces the paused factor. Sessions are left alone, as with Reset. No confirmed factor is ErrNotEnrolled; pausing a paused factor changes nothing.

func (*Plugin) DisableWith

func (p *Plugin) DisableWith(ctx context.Context, in CodeRequest) error

DisableWith is Disable with the full request, to pass the password RequirePassword asks for.

func (*Plugin) EnableFor

func (p *Plugin) EnableFor(ctx context.Context, userID string) error

EnableFor switches a paused factor back on: the next sign-in asks for it again. Every session of the user ends when it does, for the reason confirm gives: they were created without the factor. A factor that is already on is left as it is and its sessions are kept; no confirmed factor is ErrNotEnrolled, because there is nothing to ask for.

func (*Plugin) Enroll

func (p *Plugin) Enroll(ctx context.Context) (EnrollResult, error)

Enroll starts setup for the signed-in user, replacing any unconfirmed enrollment. It fails with ErrAlreadyEnabled when a factor is on: turning it off needs a code, and a fresh enrollment would bypass that. With SkipVerificationOnEnable the factor is on when it returns.

func (*Plugin) EnrollWith

func (p *Plugin) EnrollWith(ctx context.Context, in EnrollRequest) (EnrollResult, error)

EnrollWith is Enroll with a chosen method, issuer or password. MethodDelivered sends the first code at once; a method the plugin was not built with is ErrMethodInvalid.

func (*Plugin) FactorOf

func (p *Plugin) FactorOf(ctx context.Context, userID string) (method string, enabled bool, err error)

FactorOf reports userID's second factor for a support screen: the method of the confirmed factor ("" when there is none, which includes an enrolment still waiting for its first code) and whether sign-in asks for it (two_factor_enabled). A confirmed factor with enabled false is one DisableFor paused. It is the read side of the admin plugin's two-factor routes, which find this plugin by method set (admin.TwoFactorManager), so it returns plain values and the admin plugin imports nothing from here. Like Reset it checks nobody: the caller authorizes.

func (*Plugin) ForgetDevice

func (p *Plugin) ForgetDevice(ctx context.Context, in ForgetDeviceRequest) error

ForgetDevice revokes one trusted device by id, or every one with All. The next sign-in from it asks for the second factor again. When the device is the one this request came from, the transport is told to drop its token.

func (*Plugin) ImportTOTP

func (p *Plugin) ImportTOTP(ctx context.Context, userID string, secret string) error

ImportTOTP enrols userID with a TOTP secret from another system (its base32 form, as Supabase's auth.mfa_factors.secret or the otpauth URL's secret parameter), from Go only. The row is confirmed at once and two_factor_enabled is set, exactly as a confirmed Enroll leaves them, so the user's next sign-in asks for the code their existing app shows. No recovery codes are made (there is nobody to show them to; the user regenerates a set from settings). Refused: a secret that is not base32 or shorter than MinImportSecretBytes (ErrSecretInvalid), a user already enrolled (ErrAlreadyEnabled), an unknown user (auth.ErrUserNotFound), and a plugin built WithoutTOTP (ErrMethodInvalid). An unconfirmed enrolment in progress is replaced. It runs in one transaction, or joins the one in ctx.

func (*Plugin) Init

func (p *Plugin) Init(a *auth.Auth) error

Init captures the core, which the plugin's reads and writes go through, and finds the password plugin that checks the password totp-uri and RequirePassword ask for. Its absence is not an error: without passwords every user is passwordless, and the fresh session is the proof.

func (*Plugin) Methods

func (p *Plugin) Methods() []Method

Methods lists the methods a user may enrol with this configuration, in MethodValues order, for a settings page that offers the choice.

func (*Plugin) Name

func (p *Plugin) Name() string

Name is PluginName, the plugin's permanent identity in the migration ledger and in column ownership.

func (*Plugin) Operations

func (p *Plugin) Operations() []auth.Operation

Operations lists enroll, confirm, disable, verify, check, recovery-codes, status and remove-factor; send when DeliveredCodes is on (without the method the route would only ever answer ErrNotCodeMethod); devices and forget-device when TrustDevices is; totp-uri with ReadableTOTPURI; view-recovery-codes with StoreEncrypted; the security-key challenge and registration with WithSecurityKeys.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded, such as auth.ErrNilOption naming an option given nil; auth.New refuses to start while it is not nil (auth.OptionChecker).

func (*Plugin) RateLimits

func (p *Plugin) RateLimits() []auth.RateRule

RateLimits bounds every operation that takes a code or reads a secret back per IP (codeIPLimit a minute), and send (sendIPLimit per sendIPWindow) because every send costs a message. The lockout, not these rules, is what stops guessing; they stop a spray from one address from reaching the database at full speed. A rule naming an operation that is not registered is refused by auth.New, so totp-uri and view-recovery-codes get theirs only when they exist.

func (*Plugin) Reauthenticate

func (p *Plugin) Reauthenticate(ctx context.Context, in ReauthenticateRequest) (auth.Reauthentication, error)

Reauthenticate finishes a re-authentication whose first step asked for the second factor: it checks code like verify (same lockout, recovery codes spent) and then spends ticket, after which the signed-in session is fresh again. A wrong code is ErrCodeInvalid; a wrong, spent or expired ticket, or one from another session, is auth.ErrReauthTicketInvalid.

func (*Plugin) RegenerateRecoveryCodes

func (p *Plugin) RegenerateRecoveryCodes(ctx context.Context, code string) (RecoveryCodes, error)

RegenerateRecoveryCodes replaces every recovery code of the signed-in user with a new set and returns it, the only time the new codes are shown. It needs a fresh session and a current app code or an unused recovery code, like disable: whoever asks for new codes must be able to pass the factor.

func (*Plugin) RegenerateRecoveryCodesWith

func (p *Plugin) RegenerateRecoveryCodesWith(ctx context.Context, in CodeRequest) (RecoveryCodes, error)

RegenerateRecoveryCodesWith is RegenerateRecoveryCodes with the full request, to pass the password RequirePassword asks for.

func (*Plugin) RemoveFactor

func (p *Plugin) RemoveFactor(ctx context.Context, in RemoveFactorRequest) (RemoveFactorResult, error)

RemoveFactor removes one factor of the signed-in user.

func (*Plugin) Reset

func (p *Plugin) Reset(ctx context.Context, userID string) error

Reset turns userID's second factor off for them: the support desk's answer to a user who lost their phone and their recovery codes. It deletes the factor (confirmed or still being set up), every further factor and security key, every recovery code and every trusted device, and clears two_factor_enabled, in one transaction, so the next sign-in asks for the first factor only and the user can enrol again.

It asks for no code and no password, so it is a server-side call for the app to put behind its own staff check; the plugin serves no route for it, because who may reset whom is the app's rule. Sessions are left alone: the user's own were created with the factor, and an app that suspects the account was taken calls auth's DeleteUserSessions as well. A user with no factor is ErrNotEnrolled, after the same cleanup, so a mistyped id is seen.

func (*Plugin) SecondFactorRequired

func (p *Plugin) SecondFactorRequired(ctx context.Context, userID string) (bool, error)

SecondFactorRequired reports whether userID has a confirmed factor and the request is not from a device the user trusted (TrustDevices). It reads the plugin's own column on auth_user, so the usual check costs one indexed read; only an enabled user with a device token on the request costs the device lookup. With WithPolicy it asks the policy, with a sign-in that names no method; CreateSession calls SecondFactorRequiredForSignIn instead.

func (*Plugin) SecondFactorRequiredForSignIn

func (p *Plugin) SecondFactorRequiredForSignIn(ctx context.Context, in auth.SignInAttempt) (bool, error)

SecondFactorRequiredForSignIn is SecondFactorRequired for one sign-in, decided by the Policy (DefaultPolicy unless WithPolicy) for a user with a confirmed factor.

func (*Plugin) SecurityKeyChallengeFor

func (p *Plugin) SecurityKeyChallengeFor(ctx context.Context) (SecurityKeyChallenge, error)

SecurityKeyChallengeFor starts a security-key assertion for the user of the pending or signed-in session in ctx; the answer goes to verify, check, reauthenticate or a proof as CodeRequest.SecurityKey.

func (*Plugin) SecurityKeyRegisterBegin

func (p *Plugin) SecurityKeyRegisterBegin(ctx context.Context, in SecurityKeyRegisterBeginRequest) (SecurityKeyChallenge, error)

SecurityKeyRegisterBegin starts adding a security key as a second factor.

func (*Plugin) SecurityKeyRegisterFinish

func (p *Plugin) SecurityKeyRegisterFinish(ctx context.Context, in SecurityKeyRegisterFinishRequest) (SecurityKeyRegistered, error)

SecurityKeyRegisterFinish stores the key and, when it is the first factor, turns two-factor sign-in on.

func (*Plugin) Send

func (p *Plugin) Send(ctx context.Context) error

Send mails or texts a one-time code to the user of the session in ctx, for a factor enrolled with MethodDelivered: at the sign-in step (a pending session) and before disable or regenerate on a signed-in one; the operation accepts either (auth.SignedInOrPending). A TOTP user is ErrNotCodeMethod. Each call replaces the open code.

func (*Plugin) SessionTasks

func (p *Plugin) SessionTasks() []auth.SessionTask

SessionTasks declares auth.TaskSetupMFA with exactly the operations a user needs to enrol from nothing, only under RequireEnrolment.

func (*Plugin) SessionTasksFor

func (p *Plugin) SessionTasksFor(ctx context.Context, in auth.SignInAttempt) ([]auth.SessionTaskID, error)

SessionTasksFor requires auth.TaskSetupMFA of a user the rule names who has no second factor on (none enrolled, or one DisableFor paused).

func (*Plugin) Status

func (p *Plugin) Status(ctx context.Context) (Status, error)

Status reports the second-factor state of the user in ctx: a pending sign-in asks it to learn which form to show, a settings page whether the factor is on. Nothing in it is secret: a pending session exists only after the first factor was proven, and a signed-in user may always see their own settings.

func (*Plugin) Sweep

func (p *Plugin) Sweep(ctx context.Context, cutoff time.Time) (int64, error)

Sweep deletes expired trusted devices, for auth sweep.

func (*Plugin) TOTPURI

func (p *Plugin) TOTPURI(ctx context.Context, in PasswordRequest) (TOTPURIResult, error)

TOTPURI reads the signed-in user's authenticator URI back; see ReadableTOTPURI. Without that option the operation is not registered and the call fails.

func (*Plugin) Tables

func (p *Plugin) Tables() []sqlb.Table

Tables declares the plugin's tables, so auth.New can refuse another plugin claiming the same name or alias.

func (*Plugin) TypedMigrations

func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)

TypedMigrations creates the two tables for engine, and refuses an engine the core does not know.

func (*Plugin) Unlock

func (p *Plugin) Unlock(ctx context.Context, userID string) error

Unlock lifts userID's lockout before it expires and clears the count of wrong codes, for a support desk that has confirmed who is asking. The factor, its secret and the recovery codes are untouched. Like Reset it is a server-side call the app authorizes; a user with no factor is ErrNotEnrolled.

func (*Plugin) UserTables

func (p *Plugin) UserTables() []auth.UserTable

UserTables declares that the factor, further-factor, recovery-code and trusted-device rows belong to the user in their user_id column, so DeleteUser removes them (no foreign key does, ADR-0002).

func (*Plugin) Validate

func (p *Plugin) Validate(cfg *auth.Config) error

Validate requires an encryption key of auth.SealKeySize bytes, or the app's secret to derive one from: without either no secret could be stored, and every enrollment would fail at run time.

func (*Plugin) Verify

func (p *Plugin) Verify(ctx context.Context, code string) error

Verify passes the second factor for the pending session in ctx. VerifyWith with DisableSession checks the code without promoting the session; a signed-in step-up is CheckCode.

func (*Plugin) VerifyWith

func (p *Plugin) VerifyWith(ctx context.Context, in CodeRequest) error

VerifyWith is Verify with the full request, to trust the device (CodeRequest.TrustDevice) after the code passes.

func (*Plugin) ViewRecoveryCodes

func (p *Plugin) ViewRecoveryCodes(ctx context.Context, in PasswordRequest) (RecoveryCodes, error)

ViewRecoveryCodes shows the signed-in user's unused recovery codes again, as they were handed out, under StoreEncrypted (better-auth's view-backup-codes). It needs a fresh sign-in, and the password under RequirePassword. Without StoreEncrypted the operation is not registered and the call fails.

type Policy

type Policy func(ctx context.Context, in PolicyRequest) (bool, error)

Policy decides whether a sign-in of a user who has a confirmed factor asks for it: true creates a pending session, false a full one. It is consulted only for such a user, because a factor cannot be asked of someone who has none; making a user enrol is the app's own rule, not a sign-in policy. An error requires the factor: a policy that cannot decide fails closed.

type PolicyRequest

type PolicyRequest struct {
	auth.SignInAttempt
	DeviceTrusted bool
}

PolicyRequest is what a Policy decides on: the sign-in (the user, the client, the operation and sign-in method, such as "password", "oauth" or "sso") and whether the request carries a device the user trusted (TrustDevices; always false without it). A struct, so fields can be added without breaking a policy.

type ReauthenticateRequest

type ReauthenticateRequest struct {
	Code   string         `json:"code"`
	Ticket auth.WireToken `json:"ticket"`
	// Method and SecurityKey pick the factor, as on CodeRequest.
	Method      Method               `json:"method,omitempty"`
	SecurityKey *SecurityKeyResponse `json:"securityKey,omitempty"`
}

ReauthenticateRequest is the input of reauthenticate: a current code or a recovery code, and the ticket the first re-authentication step answered with (auth.Reauthentication.Ticket).

type RecoveryCodes

type RecoveryCodes struct {
	Codes []string `json:"codes"`
}

RecoveryCodes are shown at confirm or regenerate. Only their hashes are stored, unless RecoveryCodeStorage(StoreEncrypted) keeps a sealed copy that view-recovery-codes can show again.

type RecoveryStorage

type RecoveryStorage string

RecoveryStorage is how recovery codes are kept.

const (
	// StoreHashed keeps the SHA-256 hash only. The default: a leaked table
	// gives an attacker nothing to type.
	StoreHashed RecoveryStorage = "hashed"
	// StoreEncrypted also keeps each code sealed with AES-256-GCM under a key
	// derived from auth.WithSecret (KeyLabelRecoveryCodes), bound to the user
	// id. A leaked table alone still gives nothing; a leaked table AND the
	// secret give every unused code, which hashed storage never would.
	StoreEncrypted RecoveryStorage = "encrypted"
)

The storage modes.

type RemoveFactorRequest

type RemoveFactorRequest struct {
	Method   Method      `json:"method"`
	ID       string      `json:"id,omitempty"`
	Proof    FactorProof `json:"proof"`
	Password auth.Token  `json:"password,omitempty"`
}

RemoveFactorRequest names the factor to remove: a code method, or MethodSecurityKey with the key's ID. Proof is any current factor of the user (the one being removed included); Password is asked under RequirePassword.

type RemoveFactorResult

type RemoveFactorResult struct {
	Enabled bool `json:"enabled"`
}

RemoveFactorResult reports whether two-factor sign-in is still on: false after the last factor went, which also deleted the recovery codes.

type SecurityKey

type SecurityKey struct {
	ID         string     `json:"id"`
	Name       string     `json:"name"`
	CreatedAt  time.Time  `json:"createdAt"`
	LastUsedAt *time.Time `json:"lastUsedAt"`
	// AAGUID names the authenticator model when it disclosed one.
	AAGUID string `json:"aaguid,omitempty"`
	// Transports are how the key said it is reached ("usb", "nfc", …).
	Transports []string `json:"transports"`
}

SecurityKey is one registered security key, as the factor list shows it.

type SecurityKeyChallenge

type SecurityKeyChallenge struct {
	Ceremony string          `json:"ceremony"`
	Options  json.RawMessage `json:"options"`
}

SecurityKeyChallenge starts a WebAuthn ceremony: the client passes Options to navigator.credentials.create or .get and sends Ceremony back with the authenticator's answer. Ceremony is a single-use handle.

type SecurityKeyRegisterBeginRequest

type SecurityKeyRegisterBeginRequest struct {
	AuthenticatorAttachment string     `json:"authenticatorAttachment,omitempty"`
	Password                auth.Token `json:"password,omitempty"`
}

SecurityKeyRegisterBeginRequest is the input of security-key-register-begin: the authenticator kind (as passkey's), and the password when RequirePassword asks for it.

type SecurityKeyRegisterFinishRequest

type SecurityKeyRegisterFinishRequest struct {
	Ceremony   string          `json:"ceremony"`
	Credential json.RawMessage `json:"credential"`
	Name       string          `json:"name,omitempty"`
	Proof      *FactorProof    `json:"proof,omitempty"`
}

SecurityKeyRegisterFinishRequest completes the registration. Proof is required when two-factor sign-in is already on (ErrProofRequired).

type SecurityKeyRegistered

type SecurityKeyRegistered struct {
	Key           SecurityKey `json:"key"`
	RecoveryCodes []string    `json:"recoveryCodes"`
}

SecurityKeyRegistered is the new key. When it was the user's first factor, two-factor sign-in is now on and RecoveryCodes are the user's codes, shown this once; otherwise RecoveryCodes is empty and the codes the user holds stay valid.

type SecurityKeyRegistration

type SecurityKeyRegistration struct {
	Ceremony   string          `json:"ceremony"`
	Credential json.RawMessage `json:"credential"`
	Name       string          `json:"name,omitempty"`
}

SecurityKeyRegistration answers a registration ceremony; Name labels the key in the factor list.

type SecurityKeyResponse

type SecurityKeyResponse struct {
	Ceremony   string          `json:"ceremony"`
	Credential json.RawMessage `json:"credential"`
}

SecurityKeyResponse answers a security-key assertion: the ceremony from the challenge and the browser's PublicKeyCredential JSON.

type SecurityKeyStore

type SecurityKeyStore interface {
	// BeginSecurityKeyRegistration starts registering a key for u;
	// attachment is "platform", "cross-platform" or "" for either.
	BeginSecurityKeyRegistration(ctx context.Context, u auth.User, attachment string) (SecurityKeyChallenge, error)
	// FinishSecurityKeyRegistration verifies the attestation of a ceremony
	// begun for userID and stores the key.
	FinishSecurityKeyRegistration(ctx context.Context, userID string, in SecurityKeyRegistration) (SecurityKey, error)
	// BeginSecurityKeyAssertion starts an assertion naming userID's keys.
	BeginSecurityKeyAssertion(ctx context.Context, userID string) (SecurityKeyChallenge, error)
	// FinishSecurityKeyAssertion is nil only for a valid assertion, by one
	// of userID's keys, of a ceremony begun for userID and not used before.
	// Every refusal is ErrSecurityKeyInvalid; any other error is a failure
	// of the server and is not counted against the user.
	FinishSecurityKeyAssertion(ctx context.Context, userID string, in SecurityKeyResponse) error
	// SecurityKeys lists userID's keys, oldest first; never nil.
	SecurityKeys(ctx context.Context, userID string) ([]SecurityKey, error)
	// RemoveSecurityKey deletes one of userID's keys; an id that is not
	// theirs is ErrFactorNotFound.
	RemoveSecurityKey(ctx context.Context, userID, id string) error
	// RemoveSecurityKeys deletes every key of userID.
	RemoveSecurityKeys(ctx context.Context, userID string) error
}

SecurityKeyStore is what twofactor needs from a WebAuthn plugin to offer security keys as a second factor; passkey.(*Plugin).SecurityKeys returns one. The plugin owns the protocol and the storage; twofactor owns the rules (which factors a user has, the lockout, recovery codes, promotion of the session). Every method is for one user, and must refuse a key or a ceremony of another user.

type Status

type Status struct {
	Enabled bool   `json:"enabled"`
	Method  Method `json:"method,omitempty"`
	// Methods are the methods a user may enrol with this configuration, in
	// MethodValues order (the Methods function).
	Methods []Method `json:"methods"`
	// TrustDevices is true when the plugin was built with TrustDevices, so
	// the verify form may offer "remember this device".
	TrustDevices bool `json:"trustDevices"`
	// Factors are the user's confirmed factors when Enabled, the primary
	// code factor first, then further code factors, then security keys: a
	// challenge page offers one of them. Empty while off.
	Factors []Factor `json:"factors"`
}

Status is the output of status. Enabled is false until confirm, so a factor enrolled but never confirmed does not count. Method is set only when Enabled. Methods and TrustDevices describe the configuration, so one call tells a page what it may offer: the methods a user may enrol, and whether verify accepts CodeRequest.TrustDevice.

type TOTPURIResult

type TOTPURIResult struct {
	URL string `json:"totpURI"`
}

TOTPURIResult is the otpauth:// URI an authenticator app reads, with the issuer the user enrolled with. The field is named as better-auth names it.

type TrustedDevice

type TrustedDevice struct {
	ID        string     `json:"id"`
	UserAgent string     `json:"userAgent"`
	CreatedAt time.Time  `json:"createdAt"`
	ExpiresAt time.Time  `json:"expiresAt"`
	LastUsed  *time.Time `json:"lastUsedAt"`
	// Current is true for the device this request came from.
	Current bool `json:"current"`
}

TrustedDevice is one row of the device list: what a settings page shows.

type TwoFactorColumns

type TwoFactorColumns struct {
	TwoFactorEnabled bool
}

TwoFactorColumns is this plugin's flat part of a composite user: embed it in the app's user struct and load it with Bindings, so "is two-factor on" comes with the user row and costs no join.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL