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.

  1. Install peer dependencies

    The withTotpManagement mixin requires otpauth for TOTP code generation and qrcode for QR code rendering. Install them as production dependencies, and install @types/qrcode as a dev dependency.

    npm install otpauth qrcode
    npm install -D @types/qrcode
  2. Add TOTP columns to users

    Generate a migration to add five columns to your existing users table:

    • 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 text because encrypted values are longer than the original secret. The totp_last_consumed_timestep column uses bigInteger to store Unix-derived time step values without overflow.

    node ace make:migration add_totp_columns_to_users --alter=users
    database/migrations/xxxx_add_totp_columns_to_users.ts
    import { 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')
        })
      }
    }
  3. Apply the mixin

    The withTotpManagement mixin requires an encryption instance to encrypt secrets at rest and a hash instance to hash recovery codes. You can pass either a factory function or a service manager for each.

    app/models/user.ts
    import { 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.ts
    export default class User extends compose(
      UserSchema,
      withTotpManagement(encryption, hash, {
        issuer: 'My App',
        recoveryCodesCount: 8,
      })
    ) {}
    issuer
    string

    The 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".

    window
    number

    The number of time steps to allow before and after the current step when validating TOTP codes. A window of 1 accepts codes from the previous, current, and next 30-second period (approximately 90 seconds total).

    Defaults to 1.

    recoveryCodesCount
    number

    The 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', ...]
Warning

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.

app/models/user.ts
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.

app/models/user.ts
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.

app/models/user.ts
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/i18n is 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
string | null

The encrypted pending TOTP secret during enrollment. Set by generateSecret(), cleared by enableTwoFactor() or disableTwoFactor().

totpSecret
string | null

The encrypted confirmed TOTP secret. Set by enableTwoFactor() when the pending secret is promoted.

totpEnabledAt
Date | null

The timestamp when 2FA was activated.

totpLastConsumedTimestep
number | null

The last verified TOTP time step. Used for replay attack prevention.

recoveryCodes
string | null

Hashed recovery codes stored as a JSON array. Set by generateRecoveryCodes(), cleared by disableTwoFactor().

isTwoFactorEnabled
boolean

true when both totpSecret and totpEnabledAt are present.

hasPendingTwoFactor
boolean

true when totpPendingSecret is present (enrollment in progress).

remainingRecoveryCodesCount
number

The number of remaining unconsumed recovery codes. Returns 0 when none have been generated.

generateSecret
method

Generates a new TOTP secret, encrypts it, and saves it to totpPendingSecret. Does not touch the confirmed secret.

await user.generateSecret()
getSecretUri
method

Returns the otpauth:// URI from the pending secret. Throws E_TOTP_ENROLLMENT_NOT_STARTED if no pending secret exists.

const uri = user.getSecretUri()
generateQRCode
method

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
method

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
method

Verifies the code against the pending secret, promotes it to confirmed, and activates 2FA.

await user.enableTwoFactor(code)
disableTwoFactor
method

Clears all TOTP columns and recovery codes.

await user.disableTwoFactor()
verifyTotpForLogin
method

Validates a TOTP code against the confirmed secret with replay attack prevention.

await user.verifyTotpForLogin(code)
getTotpLabel
method

Returns the account label for authenticator apps. Defaults to this.email. Override to customize.

user.getTotpLabel()
createTotp
method

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
method

Generates a single recovery code in xxxxx-xxxxx format. Override to customize.

const code = user.seedRecoveryCode()
generateRecoveryCodes
method

Generates recovery codes, hashes and stores them, returns the plaintext codes. Replaces any existing codes.

const codes = await user.generateRecoveryCodes()
verifyRecoveryCode
method

Verifies a recovery code and consumes it on match.

await user.verifyRecoveryCode(code)
Terms & License Agreement