TOTP management
This guide covers the withTotpManagement mixin from @adonisplus/persona/totp. You will learn how to:
- Apply the mixin and understand the two-column secret pattern
- Enroll users in two-factor authentication with QR codes
- Verify TOTP codes during login
- Generate and verify recovery codes
- Customize TOTP parameters
Overview
Two-factor authentication (2FA) adds a second verification step after the user enters their password. The user opens an authenticator app (Google Authenticator, Authy, 1Password, etc.), reads a six-digit code, and enters it into your application. The code changes every 30 seconds.
The withTotpManagement mixin implements this using the Time-based One-Time Password (TOTP) standard (RFC 6238). It encrypts secrets at rest, generates QR codes for authenticator app setup, and tracks consumed time steps to prevent replay attacks.
The mixin uses a two-column secret pattern. Your users table stores a totp_pending_secret (the secret being enrolled) and a totp_secret (the confirmed, active secret). This separation ensures that existing 2FA stays functional while a user is setting up a new authenticator app. The pending secret is only promoted to the confirmed slot after the user proves they can generate valid codes from it.
Setup
The withTotpManagement mixin needs columns on the users table for storing secrets, timestamps, and recovery codes, and must be applied to your User model.
-
Install peer dependencies
The
withTotpManagementmixin requiresotpauthfor TOTP code generation andqrcodefor QR code rendering. Install them as production dependencies, and install@types/qrcodeas a dev dependency.npm install otpauth qrcode npm install -D @types/qrcode -
Add TOTP columns to users
Generate a migration to add five columns to your existing
userstable:- totp_pending_secret (text, nullable): Encrypted secret during enrollment.
- totp_secret (text, nullable): Encrypted confirmed secret.
- totp_enabled_at (timestamp, nullable): When 2FA was activated.
- totp_last_consumed_timestep (bigint, nullable): Last verified time step for replay prevention.
- recovery_codes (text, nullable): JSON array of hashed recovery codes.
Both secret columns use
textbecause encrypted values are longer than the original secret. Thetotp_last_consumed_timestepcolumn usesbigIntegerto store Unix-derived time step values without overflow.node ace make:migration add_totp_columns_to_users --alter=usersdatabase/migrations/xxxx_add_totp_columns_to_users.tsimport { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { protected tableName = 'users' async up() { this.schema.alterTable(this.tableName, (table) => { table.text('totp_pending_secret').nullable() table.text('totp_secret').nullable() table.timestamp('totp_enabled_at', { precision: 6, useTz: true }).nullable() table.bigInteger('totp_last_consumed_timestep').nullable() table.text('recovery_codes').nullable() }) } async down() { this.schema.alterTable(this.tableName, (table) => { table.dropColumn('totp_pending_secret') table.dropColumn('totp_secret') table.dropColumn('totp_enabled_at') table.dropColumn('totp_last_consumed_timestep') table.dropColumn('recovery_codes') }) } } -
Apply the mixin
The
withTotpManagementmixin requires anencryptioninstance to encrypt secrets at rest and ahashinstance to hash recovery codes. You can pass either a factory function or a service manager for each.app/models/user.tsimport { UserSchema } from '#database/schema' import { compose } from '@adonisjs/core/helpers' import hash from '@adonisjs/core/services/hash' import encryption from '@adonisjs/core/services/encryption' import { withTotpManagement } from '@adonisplus/persona/totp' export default class User extends compose( UserSchema, withTotpManagement(encryption, hash) ) {}You can customize the mixin by passing a config object as the third argument.
app/models/user.tsexport default class User extends compose( UserSchema, withTotpManagement(encryption, hash, { issuer: 'My App', recoveryCodesCount: 8, }) ) {}issuerstringThe issuer name displayed in the authenticator app when the user scans the QR code. Helps users identify which service the code belongs to.
Defaults to
"AdonisJS App".windownumberThe number of time steps to allow before and after the current step when validating TOTP codes. A window of
1accepts codes from the previous, current, and next 30-second period (approximately 90 seconds total).Defaults to
1.recoveryCodesCountnumberThe number of recovery codes to generate when calling
generateRecoveryCodes(). Each code is a single-use backup that can verify identity when the authenticator device is unavailable.Defaults to
10.
Enrollment
Enrollment is a multi-step process. You generate a secret, show the user a QR code to scan with their authenticator app, and then ask them to enter a code to prove the setup worked. Only after verification does 2FA become active.
Generate the secret and show the QR code
// Generates and encrypts a new pending secret
await user.generateSecret()
// QR code as a base64 data URL, render directly in an <img> tag
const qrCodeDataUrl = await user.generateQRCode()
// For manual entry when the user cannot scan the QR code
const manualSecret = user.getEnrollmentSecret().release()
Calling generateSecret() is safe during re-enrollment. It overwrites any existing pending secret without touching the confirmed one, so login verification continues to work with the old authenticator app.
Verify and activate
After the user scans the QR code, ask them to enter the six-digit code from their authenticator app. The enableTwoFactor method verifies the code against the pending secret and promotes it to the confirmed slot.
await user.enableTwoFactor(code)
If the code is invalid, the method throws E_INVALID_TOTP_TOKEN. If no pending secret exists, it throws E_TOTP_ENROLLMENT_NOT_STARTED.
After enableTwoFactor succeeds, user.isTwoFactorEnabled returns true.
Multiple devices
When enableTwoFactor promotes the pending secret, it replaces the previous confirmed secret. This means any device using the old secret will stop generating valid codes.
If a user wants to set up multiple authenticator devices, they must scan the QR code from all devices during the same enrollment session, before calling enableTwoFactor. Since all devices share the same secret, they will all generate identical codes at any given time. Verifying with a code from any one of them completes the enrollment for all.
Verifying TOTP during login
Once 2FA is enabled, verify a TOTP code as part of your login flow. The typical approach is a two-step login: first verify the password, then check isTwoFactorEnabled and prompt for a code.
// After password verification
if (user.isTwoFactorEnabled) {
// Redirect to TOTP challenge page
}
When the user submits their code, verify it with verifyTotpForLogin. This method validates the code against the confirmed secret and prevents replay attacks by tracking the last consumed time step.
await user.verifyTotpForLogin(code)
If the code is invalid or has already been used, the method throws E_INVALID_TOTP_TOKEN.
Disabling two-factor authentication
Call disableTwoFactor to clear all TOTP columns and recovery codes.
await user.disableTwoFactor()
You should require the user to re-authenticate (password confirmation or a valid TOTP code) before allowing them to disable 2FA. Without this check, an attacker with access to an active session can silently disable 2FA.
Recovery codes
Recovery codes are single-use backup codes that let users regain access when they lose their authenticator device. Each code is a 10-character alphanumeric string in xxxxx-xxxxx format, hashed before storage.
Recovery codes are independent from the TOTP secret lifecycle. Generating recovery codes does not affect the TOTP secret, and re-enrolling does not clear existing recovery codes. However, disableTwoFactor clears both.
Generating recovery codes
After the user completes 2FA enrollment, generate recovery codes and display them once. The plaintext codes cannot be retrieved again.
const codes = await user.generateRecoveryCodes()
// ['a1b2c-d3e4f', 'g5h6i-j7k8l', ...]
Calling generateRecoveryCodes again replaces any existing recovery codes. The old codes will no longer work.
Verifying recovery codes
When a user cannot provide a TOTP code, allow them to enter a recovery code instead. The verifyRecoveryCode method finds and consumes the matching code in a single step.
await user.verifyRecoveryCode(recoveryCode)
If the code is invalid, already consumed, or no recovery codes exist, the method throws E_INVALID_RECOVERY_CODE.
Right after a successful verifyRecoveryCode call is the best place to check if the user is running low on recovery codes. The count just decreased, and the user already knows their authenticator is unavailable, so a notification at this point is timely and actionable.
await user.verifyRecoveryCode(recoveryCode)
if (user.remainingRecoveryCodesCount <= 2) {
// Send a notification prompting the user to generate new codes
}
Checking remaining codes
Use remainingRecoveryCodesCount to check how many recovery codes the user has left.
user.remainingRecoveryCodesCount
Customization
You can override several methods on the model to customize the TOTP behavior without changing the mixin configuration.
Custom label
By default, the mixin uses the user's email property as the TOTP label shown in authenticator apps. Override getTotpLabel to use a different value.
export default class User extends compose(UserSchema, withTotpManagement(encryption, hash)) {
getTotpLabel(): string {
return this.username
}
}
Custom TOTP instance
Override createTotp to change the TOTP algorithm, digit count, or period. Most authenticator apps only support SHA1 with 6 digits and a 30-second period, so change these values only if you are certain your target apps support them.
import { TOTP } from 'otpauth'
export default class User extends compose(
UserSchema,
withTotpManagement(encryption, hash, { issuer: 'My App' })
) {
createTotp(secret?: string): TOTP {
return new TOTP({
issuer: 'My App',
label: this.getTotpLabel(),
algorithm: 'SHA1',
digits: 6,
period: 30,
secret,
})
}
}
Custom recovery code format
Override seedRecoveryCode to customize the format or generation strategy.
export default class User extends compose(UserSchema, withTotpManagement(encryption, hash)) {
seedRecoveryCode(): string {
// Your custom generation logic
}
}
Error handling
The TOTP module throws three exceptions. All use the same content-negotiation pattern as other Persona errors:
- HTML requests: Flash the error message to the session and redirect back.
- JSON requests: Return
{ errors: [{ message: "..." }] }with a 400 status. - JSONAPI requests: Return
{ errors: [{ code: "...", title: "..." }] }. - i18n: Translated using the corresponding
errors.*key if@adonisjs/i18nis configured.
E_TOTP_ENROLLMENT_NOT_STARTED
Thrown when calling a method that requires an active enrollment (like getSecretUri or getEnrollmentSecret) but no pending secret exists. Also thrown by verifyTotpForLogin and verifyRecoveryCode when 2FA is not enabled.
E_INVALID_TOTP_TOKEN
Thrown when the TOTP code is invalid, expired, or has already been used (replay attack).
E_INVALID_RECOVERY_CODE
Thrown when the recovery code is invalid, already consumed, or no recovery codes exist.
Instance properties and methods
totpPendingSecret
The encrypted pending TOTP secret during enrollment. Set by generateSecret(), cleared by enableTwoFactor() or disableTwoFactor().
totpSecret
The encrypted confirmed TOTP secret. Set by enableTwoFactor() when the pending secret is promoted.
totpEnabledAt
The timestamp when 2FA was activated.
totpLastConsumedTimestep
The last verified TOTP time step. Used for replay attack prevention.
recoveryCodes
Hashed recovery codes stored as a JSON array. Set by generateRecoveryCodes(), cleared by disableTwoFactor().
isTwoFactorEnabled
true when both totpSecret and totpEnabledAt are present.
hasPendingTwoFactor
true when totpPendingSecret is present (enrollment in progress).
remainingRecoveryCodesCount
The number of remaining unconsumed recovery codes. Returns 0 when none have been generated.
generateSecret
Generates a new TOTP secret, encrypts it, and saves it to totpPendingSecret. Does not touch the confirmed secret.
await user.generateSecret()
getSecretUri
Returns the otpauth:// URI from the pending secret. Throws E_TOTP_ENROLLMENT_NOT_STARTED if no pending secret exists.
const uri = user.getSecretUri()
generateQRCode
Returns a QR code as a base64-encoded data URL from the pending secret. Throws E_TOTP_ENROLLMENT_NOT_STARTED if no pending secret exists.
const dataUrl = await user.generateQRCode()
getEnrollmentSecret
Returns the decrypted pending secret wrapped in the Secret class. For manual entry when the user cannot scan the QR code. Throws E_TOTP_ENROLLMENT_NOT_STARTED if no pending secret exists.
const secret = user.getEnrollmentSecret().release()
enableTwoFactor
Verifies the code against the pending secret, promotes it to confirmed, and activates 2FA.
await user.enableTwoFactor(code)
disableTwoFactor
Clears all TOTP columns and recovery codes.
await user.disableTwoFactor()
verifyTotpForLogin
Validates a TOTP code against the confirmed secret with replay attack prevention.
await user.verifyTotpForLogin(code)
getTotpLabel
Returns the account label for authenticator apps. Defaults to this.email. Override to customize.
user.getTotpLabel()
createTotp
Creates a TOTP instance from the otpauth library. Override to customize algorithm, digits, or period.
const totp = user.createTotp()
const totp = user.createTotp(existingSecret)
seedRecoveryCode
Generates a single recovery code in xxxxx-xxxxx format. Override to customize.
const code = user.seedRecoveryCode()
generateRecoveryCodes
Generates recovery codes, hashes and stores them, returns the plaintext codes. Replaces any existing codes.
const codes = await user.generateRecoveryCodes()
verifyRecoveryCode
Verifies a recovery code and consumes it on match.
await user.verifyRecoveryCode(code)