Backup Codes for WebAuthn: The Safety Net

Learn how to implement secure backup codes for WebAuthn in Rails

Published

Published

Feb 18, 2026

Feb 18, 2026

Topic

Topic

Engineering

Engineering

Written by

Written by

Ali Fadel, Ibraheem Tuffaha

Ali Fadel, Ibraheem Tuffaha

Backup Codes for WebAuthn: The Safety Net

This is Part 3 of a 4-part series on implementing WebAuthn in Rails with Devise and Inertia.js.

Part

Title

1

Setup and Registration

2

Authentication: From 2FA to Password-less

3

Backup Codes: The Safety Net (you are here)

4

Design Decisions and Trade-offs

In Part 1, we set up WebAuthn and built passkey registration. In Part 2, we implemented authentication - first as 2FA, then as password-less.

But there's a problem. What happens when a user loses their phone? Or their hardware key breaks? They're locked out of their account with no way back in.

Backup codes solve this. They're single-use recovery codes that users can enter when they can't use their passkey. In this post, we'll implement backup code generation, storage, authentication, and regeneration.

What you'll need to update from previous parts: If you've been following along, this part requires changes to code from Parts 1 and 2:

  • The credentials controller from Part 1 gets refactored to return JSON (so we can send backup codes to the frontend)

  • The registration modal from Part 1 changes from form.post() to fetch() to handle JSON responses

  • The TwoFactorAuth page from Part 2 gets a backup code entry form added

We'll show the complete updated code for each of these.

How Backup Codes Work

The idea is simple: when users register their first passkey, we generate 10 random codes. They save these codes somewhere safe (password manager, printed paper, etc.). If they ever lose access to their passkey, they can sign in with one of these codes. Each code only works once.

Backup Code Generation: When a user registers their first passkey, we generate backup codes automatically. The codes are shown once in a modal, and the user must save them. We only store hashed versions in the database - if someone compromises the database, they can't use the codes.

Backup Code Usage: When a user can't access their passkey (lost phone, broken hardware key), they can use a backup code instead. Each code works only once - after use, it's marked as consumed and can't be reused.

The Database Schema [Backend]

Backup codes need their own table. Each code is stored separately rather than as an array because we need to track which codes have been used:

# db/migrate/create_backup_codes.rb
class CreateBackupCodes < ActiveRecord::Migration[8.0]
  def change
    create_table :backup_codes, id: :uuid do |t|
      t.references :user, null: false, foreign_key: true, type: :uuid
      t.string :code_digest, null: false
      t.datetime :used_at

      t.timestamps
    end
  end
end
# db/migrate/create_backup_codes.rb
class CreateBackupCodes < ActiveRecord::Migration[8.0]
  def change
    create_table :backup_codes, id: :uuid do |t|
      t.references :user, null: false, foreign_key: true, type: :uuid
      t.string :code_digest, null: false
      t.datetime :used_at

      t.timestamps
    end
  end
end
# db/migrate/create_backup_codes.rb
class CreateBackupCodes < ActiveRecord::Migration[8.0]
  def change
    create_table :backup_codes, id: :uuid do |t|
      t.references :user, null: false, foreign_key: true, type: :uuid
      t.string :code_digest, null: false
      t.datetime :used_at

      t.timestamps
    end
  end
end

The columns:

  • user_id: References the user who owns the codes

  • code_digest: The BCrypt hash of the code (we never store plaintext)

  • used_at: When the code was consumed (null means unused, a timestamp means used)

The used_at column serves two purposes. First, it lets us query for unused codes (where(used_at: nil)). Second, it provides an audit trail. If you need to investigate suspicious account activity, you can see exactly when backup codes were used.

We also need fields on the User model for rate limiting:

# db/migrate/add_backup_code_fields_to_users.rb
class AddBackupCodeFieldsToUsers < ActiveRecord::Migration[8.0]
  def change
    add_column :users, :failed_backup_code_attempts, :integer, default: 0, null: false
    add_column :users, :locked_backup_codes_until, :datetime
  end
end
# db/migrate/add_backup_code_fields_to_users.rb
class AddBackupCodeFieldsToUsers < ActiveRecord::Migration[8.0]
  def change
    add_column :users, :failed_backup_code_attempts, :integer, default: 0, null: false
    add_column :users, :locked_backup_codes_until, :datetime
  end
end
# db/migrate/add_backup_code_fields_to_users.rb
class AddBackupCodeFieldsToUsers < ActiveRecord::Migration[8.0]
  def change
    add_column :users, :failed_backup_code_attempts, :integer, default: 0, null: false
    add_column :users, :locked_backup_codes_until, :datetime
  end
end

The BackupCode Model [Backend]

# app/models/backup_code.rb
require 'bcrypt'

class BackupCode < ApplicationRecord
  belongs_to :user

  validates :code_digest, presence: true

  scope :unused, -> { where(used_at: nil) }
  scope :used, -> { where.not(used_at: nil) }

  def verify(plaintext_code)
    return false if used?

    BCrypt::Password.new(code_digest).is_password?(normalize(plaintext_code))
  end

  def used?
    used_at.present?
  end

  def mark_as_used!
    raise 'Backup code already used' if reload.used?

    update!(used_at: Time.current)
  end

  private

  def normalize(code)
    code.to_s.upcase.gsub(/[^A-Z0-9]/, '')
  end
end
# app/models/backup_code.rb
require 'bcrypt'

class BackupCode < ApplicationRecord
  belongs_to :user

  validates :code_digest, presence: true

  scope :unused, -> { where(used_at: nil) }
  scope :used, -> { where.not(used_at: nil) }

  def verify(plaintext_code)
    return false if used?

    BCrypt::Password.new(code_digest).is_password?(normalize(plaintext_code))
  end

  def used?
    used_at.present?
  end

  def mark_as_used!
    raise 'Backup code already used' if reload.used?

    update!(used_at: Time.current)
  end

  private

  def normalize(code)
    code.to_s.upcase.gsub(/[^A-Z0-9]/, '')
  end
end
# app/models/backup_code.rb
require 'bcrypt'

class BackupCode < ApplicationRecord
  belongs_to :user

  validates :code_digest, presence: true

  scope :unused, -> { where(used_at: nil) }
  scope :used, -> { where.not(used_at: nil) }

  def verify(plaintext_code)
    return false if used?

    BCrypt::Password.new(code_digest).is_password?(normalize(plaintext_code))
  end

  def used?
    used_at.present?
  end

  def mark_as_used!
    raise 'Backup code already used' if reload.used?

    update!(used_at: Time.current)
  end

  private

  def normalize(code)
    code.to_s.upcase.gsub(/[^A-Z0-9]/, '')
  end
end

Backup codes are stored as BCrypt hashes, not plaintext. If your database is compromised, attackers can't use the codes.

The normalize method strips formatting. We display codes as XXXXX-XXXXX but accept input with or without the dash. The normalization makes verification flexible:

def normalize(code)
  code.to_s.upcase.gsub(/[^A-Z0-9]/, '')
end
def normalize(code)
  code.to_s.upcase.gsub(/[^A-Z0-9]/, '')
end
def normalize(code)
  code.to_s.upcase.gsub(/[^A-Z0-9]/, '')
end

The mark_as_used! method includes a reload to prevent race conditions. If two requests try to use the same code simultaneously, only one will succeed:

def mark_as_used!
  raise 'Backup code already used' if reload.used?

  update!(used_at: Time.current)
end
def mark_as_used!
  raise 'Backup code already used' if reload.used?

  update!(used_at: Time.current)
end
def mark_as_used!
  raise 'Backup code already used' if reload.used?

  update!(used_at: Time.current)
end

The Generator Service [Backend]

The generator service creates a set of backup codes for a user. It handles the full lifecycle: deleting old codes, generating new ones, hashing them securely, and returning the plaintext versions (which we show to the user once and never store).

# app/services/backup_codes/generator.rb
require 'bcrypt'

class BackupCodes::Generator
  CODES_COUNT = 10
  CODE_LENGTH = 10
  # Excludes I, O, 0, 1 to avoid confusion when reading codes
  CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'.chars.freeze

  def initialize(user)
    @user = user
  end

  def call
    plaintext_codes = nil

    @user.backup_codes.transaction do
      # Delete existing codes
      @user.backup_codes.delete_all

      # Reset rate limiting
      @user.update!(failed_backup_code_attempts: 0, locked_backup_codes_until: nil)

      # Generate new codes
      plaintext_codes = generate_codes
      store_hashed_codes(plaintext_codes)
    end

    # Return formatted codes (with dashes)
    plaintext_codes.map { format_code(it) }
  end

  private

  def generate_codes
    codes = Set.new
    codes << generate_code until codes.size == CODES_COUNT
    codes.to_a
  end

  def generate_code
    SecureRandom.random_bytes(CODE_LENGTH).bytes.map { CHARSET[it % CHARSET.length] }.join
  end

  def store_hashed_codes(plaintext_codes)
    plaintext_codes.each { @user.backup_codes.create!(code_digest: hash_code(it)) }
  end

  def hash_code(code)
    BCrypt::Password.create(code, cost: 12)
  end

  def format_code(code)
    "#{code[0..4]}-#{code[5..9]}"
  end
end
# app/services/backup_codes/generator.rb
require 'bcrypt'

class BackupCodes::Generator
  CODES_COUNT = 10
  CODE_LENGTH = 10
  # Excludes I, O, 0, 1 to avoid confusion when reading codes
  CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'.chars.freeze

  def initialize(user)
    @user = user
  end

  def call
    plaintext_codes = nil

    @user.backup_codes.transaction do
      # Delete existing codes
      @user.backup_codes.delete_all

      # Reset rate limiting
      @user.update!(failed_backup_code_attempts: 0, locked_backup_codes_until: nil)

      # Generate new codes
      plaintext_codes = generate_codes
      store_hashed_codes(plaintext_codes)
    end

    # Return formatted codes (with dashes)
    plaintext_codes.map { format_code(it) }
  end

  private

  def generate_codes
    codes = Set.new
    codes << generate_code until codes.size == CODES_COUNT
    codes.to_a
  end

  def generate_code
    SecureRandom.random_bytes(CODE_LENGTH).bytes.map { CHARSET[it % CHARSET.length] }.join
  end

  def store_hashed_codes(plaintext_codes)
    plaintext_codes.each { @user.backup_codes.create!(code_digest: hash_code(it)) }
  end

  def hash_code(code)
    BCrypt::Password.create(code, cost: 12)
  end

  def format_code(code)
    "#{code[0..4]}-#{code[5..9]}"
  end
end
# app/services/backup_codes/generator.rb
require 'bcrypt'

class BackupCodes::Generator
  CODES_COUNT = 10
  CODE_LENGTH = 10
  # Excludes I, O, 0, 1 to avoid confusion when reading codes
  CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'.chars.freeze

  def initialize(user)
    @user = user
  end

  def call
    plaintext_codes = nil

    @user.backup_codes.transaction do
      # Delete existing codes
      @user.backup_codes.delete_all

      # Reset rate limiting
      @user.update!(failed_backup_code_attempts: 0, locked_backup_codes_until: nil)

      # Generate new codes
      plaintext_codes = generate_codes
      store_hashed_codes(plaintext_codes)
    end

    # Return formatted codes (with dashes)
    plaintext_codes.map { format_code(it) }
  end

  private

  def generate_codes
    codes = Set.new
    codes << generate_code until codes.size == CODES_COUNT
    codes.to_a
  end

  def generate_code
    SecureRandom.random_bytes(CODE_LENGTH).bytes.map { CHARSET[it % CHARSET.length] }.join
  end

  def store_hashed_codes(plaintext_codes)
    plaintext_codes.each { @user.backup_codes.create!(code_digest: hash_code(it)) }
  end

  def hash_code(code)
    BCrypt::Password.create(code, cost: 12)
  end

  def format_code(code)
    "#{code[0..4]}-#{code[5..9]}"
  end
end

Let's break down the design decisions.

The charset excludes ambiguous characters. 0 looks like O. 1 looks like I. Removing these prevents user errors when transcribing codes:

CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'.chars.freeze
CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'.chars.freeze
CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'.chars.freeze

We use a Set to ensure uniqueness:

codes = Set.new
codes << generate_code until codes.size == CODES_COUNT
codes = Set.new
codes << generate_code until codes.size == CODES_COUNT
codes = Set.new
codes << generate_code until codes.size == CODES_COUNT

The transaction ensures atomicity. If hashing fails partway through, we don't end up with partial code sets:

@user.backup_codes.transaction do
  @user.backup_codes.delete_all
  # ...
end
@user.backup_codes.transaction do
  @user.backup_codes.delete_all
  # ...
end
@user.backup_codes.transaction do
  @user.backup_codes.delete_all
  # ...
end

We reset the failed attempt counter when generating new codes. If a user was locked out, regenerating codes gives them a fresh start:

@user.update!(failed_backup_code_attempts: 0, locked_backup_codes_until: nil
@user.update!(failed_backup_code_attempts: 0, locked_backup_codes_until: nil
@user.update!(failed_backup_code_attempts: 0, locked_backup_codes_until: nil

The generated codes get formatted with a dash for readability. ABCDE12345 becomes ABCDE-12345. This makes it easier to read and transcribe.

Auto-Generating Codes on First Passkey Registration [Backend]

When users register their first passkey, we automatically generate backup codes. This is important. Without backup codes, losing a passkey means losing account access.

Update the credentials controller we built in Part 1. Here's the complete version with backup code generation:

# app/controllers/web_authn/credentials_controller.rb
class WebAuthn::CredentialsController < ApplicationController
  skip_before_action :underscore_params, only: [:create]

  def create
    # Same as Part 1

    if @credential.save
      session.delete(WebAuthn::REGISTRATION_CHALLENGE_KEY)
      render json: {
        backup_codes: generate_backup_codes_if_first_credential,
        message: 'Passkey registered successfully'
      }, status: :created
    else
      render json: { errors: @credential.errors.to_hash(true).transform_values(&:first) },
             status: :unprocessable_content
    end
  end

  def destroy
    # Same as Part 1
  end

  private

  def verify_web_authn_credential
    # Same as Part 1
  rescue ::WebAuthn::Error => e
    session.delete(WebAuthn::REGISTRATION_CHALLENGE_KEY)
    render json: { error: "Registration failed: #{e.message}" }, status: :unprocessable_content
    nil
  end

  def build_web_authn_credential(web_authn_credential)
    # Same as Part 1
  end

  def generate_backup_codes_if_first_credential
    return nil if current_user.web_authn_credentials.many?

    BackupCodes::Generator.new(current_user).call
  end
end
# app/controllers/web_authn/credentials_controller.rb
class WebAuthn::CredentialsController < ApplicationController
  skip_before_action :underscore_params, only: [:create]

  def create
    # Same as Part 1

    if @credential.save
      session.delete(WebAuthn::REGISTRATION_CHALLENGE_KEY)
      render json: {
        backup_codes: generate_backup_codes_if_first_credential,
        message: 'Passkey registered successfully'
      }, status: :created
    else
      render json: { errors: @credential.errors.to_hash(true).transform_values(&:first) },
             status: :unprocessable_content
    end
  end

  def destroy
    # Same as Part 1
  end

  private

  def verify_web_authn_credential
    # Same as Part 1
  rescue ::WebAuthn::Error => e
    session.delete(WebAuthn::REGISTRATION_CHALLENGE_KEY)
    render json: { error: "Registration failed: #{e.message}" }, status: :unprocessable_content
    nil
  end

  def build_web_authn_credential(web_authn_credential)
    # Same as Part 1
  end

  def generate_backup_codes_if_first_credential
    return nil if current_user.web_authn_credentials.many?

    BackupCodes::Generator.new(current_user).call
  end
end
# app/controllers/web_authn/credentials_controller.rb
class WebAuthn::CredentialsController < ApplicationController
  skip_before_action :underscore_params, only: [:create]

  def create
    # Same as Part 1

    if @credential.save
      session.delete(WebAuthn::REGISTRATION_CHALLENGE_KEY)
      render json: {
        backup_codes: generate_backup_codes_if_first_credential,
        message: 'Passkey registered successfully'
      }, status: :created
    else
      render json: { errors: @credential.errors.to_hash(true).transform_values(&:first) },
             status: :unprocessable_content
    end
  end

  def destroy
    # Same as Part 1
  end

  private

  def verify_web_authn_credential
    # Same as Part 1
  rescue ::WebAuthn::Error => e
    session.delete(WebAuthn::REGISTRATION_CHALLENGE_KEY)
    render json: { error: "Registration failed: #{e.message}" }, status: :unprocessable_content
    nil
  end

  def build_web_authn_credential(web_authn_credential)
    # Same as Part 1
  end

  def generate_backup_codes_if_first_credential
    return nil if current_user.web_authn_credentials.many?

    BackupCodes::Generator.new(current_user).call
  end
end

This is a significant change from the Part 1 version. We now return JSON for all responses, not just WebAuthn errors. Why? Because we need to return backup codes to the frontend on the first passkey registration. The frontend modal needs the actual codes to display them to the user.

The generate_backup_codes_if_first_credential method returns nil for subsequent passkeys (when the user already has credentials), so the JSON response will have backup_codes: null in that case. The frontend handles both cases.

Handling Backup Codes in Registration [Frontend]

Since the backend now always returns JSON, we update the registration modal to handle the response uniformly. The key change is checking whether backup_codes is present in the response:

<script setup lang="ts">
import { router } from '@inertiajs/vue3'
import { ref } from 'vue'
import BackupCodesModal from '~/components/users/BackupCodesModal.vue'
import { web_authn_credentials_path } from '~/routes.js'
import { getCsrfToken } from '~/utils/csrf'

// State for backup codes (only shown for first passkey)
const showBackupCodesModal = ref(false)
const backupCodes = ref<string[]>([])
const successMessage = ref<string | null>(null)

const toast = useToast()

async function submitNickname() {
  if (!pendingCredential.value) return

  nicknameError.value = null

  if (!nickname.value.trim()) {
    nicknameError.value = "Nickname can't be blank"
    return
  }

  isRegistering.value = true

  try {
    const response = await fetch(web_authn_credentials_path(), {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': getCsrfToken()
      },
      body: JSON.stringify({
        credential: pendingCredential.value,
        nickname: nickname.value.trim()
      })
    })

    if (!response.ok) {
      const errorData = await response.json()
      handleServerError(errorData)
      return
    }

    const data = await response.json()
    successMessage.value = data.message

    // If backup codes are returned, show them (first passkey only)
    if (data.backup_codes) {
      backupCodes.value = data.backup_codes
      showBackupCodesModal.value = true
    } else {
      // Subsequent passkey - just complete registration
      handleRegistrationComplete()
    }
  } catch {
    isRegistering.value = false
    clientError.value = 'Registration failed. Please refresh and try again.'
  }
}
</script>
<script setup lang="ts">
import { router } from '@inertiajs/vue3'
import { ref } from 'vue'
import BackupCodesModal from '~/components/users/BackupCodesModal.vue'
import { web_authn_credentials_path } from '~/routes.js'
import { getCsrfToken } from '~/utils/csrf'

// State for backup codes (only shown for first passkey)
const showBackupCodesModal = ref(false)
const backupCodes = ref<string[]>([])
const successMessage = ref<string | null>(null)

const toast = useToast()

async function submitNickname() {
  if (!pendingCredential.value) return

  nicknameError.value = null

  if (!nickname.value.trim()) {
    nicknameError.value = "Nickname can't be blank"
    return
  }

  isRegistering.value = true

  try {
    const response = await fetch(web_authn_credentials_path(), {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': getCsrfToken()
      },
      body: JSON.stringify({
        credential: pendingCredential.value,
        nickname: nickname.value.trim()
      })
    })

    if (!response.ok) {
      const errorData = await response.json()
      handleServerError(errorData)
      return
    }

    const data = await response.json()
    successMessage.value = data.message

    // If backup codes are returned, show them (first passkey only)
    if (data.backup_codes) {
      backupCodes.value = data.backup_codes
      showBackupCodesModal.value = true
    } else {
      // Subsequent passkey - just complete registration
      handleRegistrationComplete()
    }
  } catch {
    isRegistering.value = false
    clientError.value = 'Registration failed. Please refresh and try again.'
  }
}
</script>
<script setup lang="ts">
import { router } from '@inertiajs/vue3'
import { ref } from 'vue'
import BackupCodesModal from '~/components/users/BackupCodesModal.vue'
import { web_authn_credentials_path } from '~/routes.js'
import { getCsrfToken } from '~/utils/csrf'

// State for backup codes (only shown for first passkey)
const showBackupCodesModal = ref(false)
const backupCodes = ref<string[]>([])
const successMessage = ref<string | null>(null)

const toast = useToast()

async function submitNickname() {
  if (!pendingCredential.value) return

  nicknameError.value = null

  if (!nickname.value.trim()) {
    nicknameError.value = "Nickname can't be blank"
    return
  }

  isRegistering.value = true

  try {
    const response = await fetch(web_authn_credentials_path(), {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': getCsrfToken()
      },
      body: JSON.stringify({
        credential: pendingCredential.value,
        nickname: nickname.value.trim()
      })
    })

    if (!response.ok) {
      const errorData = await response.json()
      handleServerError(errorData)
      return
    }

    const data = await response.json()
    successMessage.value = data.message

    // If backup codes are returned, show them (first passkey only)
    if (data.backup_codes) {
      backupCodes.value = data.backup_codes
      showBackupCodesModal.value = true
    } else {
      // Subsequent passkey - just complete registration
      handleRegistrationComplete()
    }
  } catch {
    isRegistering.value = false
    clientError.value = 'Registration failed. Please refresh and try again.'
  }
}
</script>

The response handling is now simpler. We always use fetch() and check if data.backup_codes is present. If it is (first passkey), we show the backup codes modal. If not (subsequent passkey), we complete the registration immediately.

After the user confirms they've saved their backup codes, we clean up and reload the page:

<script setup lang="ts">
// ... previous code

function handleRegistrationComplete() {
  isRegistering.value = false
  open.value = false

  if (successMessage.value) {
    toast.add({
      title: successMessage.value,
      color: 'neutral'
    })
  }

  router.reload()
}
</script>
<script setup lang="ts">
// ... previous code

function handleRegistrationComplete() {
  isRegistering.value = false
  open.value = false

  if (successMessage.value) {
    toast.add({
      title: successMessage.value,
      color: 'neutral'
    })
  }

  router.reload()
}
</script>
<script setup lang="ts">
// ... previous code

function handleRegistrationComplete() {
  isRegistering.value = false
  open.value = false

  if (successMessage.value) {
    toast.add({
      title: successMessage.value,
      color: 'neutral'
    })
  }

  router.reload()
}
</script>

The Backup Codes Modal [Frontend]

When backup codes are generated, users must acknowledge saving them before continuing:

<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { computed, ref } from 'vue'

const props = defineProps<{ codes: string[] }>()
const open = defineModel<boolean>('open', { required: true })
const emit = defineEmits<{ confirmed: [] }>()

const hasSaved = ref(false)
const downloadLink = ref<HTMLAnchorElement | null>(null)

// Prepare codes for clipboard (one per line)
const codesText = computed(() => props.codes.join('\n'))
const { copy, copied } = useClipboard({ source: codesText, copiedDuring: 2000 })

function download() {
  const text = `Backup Codes\n============\n\n${props.codes.join('\n')}\n\nKeep these codes safe. Each code can only be used once.`
  const blob = new Blob([text], { type: 'text/plain' })
  const url = URL.createObjectURL(blob)

  if (downloadLink.value) {
    downloadLink.value.href = url
    downloadLink.value.click()
    URL.revokeObjectURL(url)
  }
}

function handleConfirm() {
  emit('confirmed')
  open.value = false
}
</script>

<template>
  <UModal
    v-model:open="open"
    title="Save your backup codes"
    :close="false"
    :dismissible="false"
  >
    <template #body>
      <UAlert
        color="warning"
        title="Save these codes now"
        description="You won't be able to see them again. Store them in a safe place like a password manager."
      />

      <div class="grid grid-cols-2 gap-2 font-mono">
        <div
          v-for="code in codes"
          :key="code"
        >
          {{ code }}
        </div>
      </div>

      <div class="flex gap-2">
        <UButton @click="copy()">
          {{ copied ? 'Copied!' : 'Copy all' }}
        </UButton>
        <UButton @click="download">Download</UButton>
      </div>

      <UCheckbox
        v-model="hasSaved"
        label="I have saved these backup codes in a safe place"
      />

      <UButton
        :disabled="!hasSaved"
        @click="handleConfirm"
      >
        I've saved my codes
      </UButton>

      <!-- Hidden download link for Vue-compatible file download -->
      <a
        ref="downloadLink"
        download="backup-codes.txt"
        class="hidden"
      />
    </template>
  </UModal>
</template>
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { computed, ref } from 'vue'

const props = defineProps<{ codes: string[] }>()
const open = defineModel<boolean>('open', { required: true })
const emit = defineEmits<{ confirmed: [] }>()

const hasSaved = ref(false)
const downloadLink = ref<HTMLAnchorElement | null>(null)

// Prepare codes for clipboard (one per line)
const codesText = computed(() => props.codes.join('\n'))
const { copy, copied } = useClipboard({ source: codesText, copiedDuring: 2000 })

function download() {
  const text = `Backup Codes\n============\n\n${props.codes.join('\n')}\n\nKeep these codes safe. Each code can only be used once.`
  const blob = new Blob([text], { type: 'text/plain' })
  const url = URL.createObjectURL(blob)

  if (downloadLink.value) {
    downloadLink.value.href = url
    downloadLink.value.click()
    URL.revokeObjectURL(url)
  }
}

function handleConfirm() {
  emit('confirmed')
  open.value = false
}
</script>

<template>
  <UModal
    v-model:open="open"
    title="Save your backup codes"
    :close="false"
    :dismissible="false"
  >
    <template #body>
      <UAlert
        color="warning"
        title="Save these codes now"
        description="You won't be able to see them again. Store them in a safe place like a password manager."
      />

      <div class="grid grid-cols-2 gap-2 font-mono">
        <div
          v-for="code in codes"
          :key="code"
        >
          {{ code }}
        </div>
      </div>

      <div class="flex gap-2">
        <UButton @click="copy()">
          {{ copied ? 'Copied!' : 'Copy all' }}
        </UButton>
        <UButton @click="download">Download</UButton>
      </div>

      <UCheckbox
        v-model="hasSaved"
        label="I have saved these backup codes in a safe place"
      />

      <UButton
        :disabled="!hasSaved"
        @click="handleConfirm"
      >
        I've saved my codes
      </UButton>

      <!-- Hidden download link for Vue-compatible file download -->
      <a
        ref="downloadLink"
        download="backup-codes.txt"
        class="hidden"
      />
    </template>
  </UModal>
</template>
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { computed, ref } from 'vue'

const props = defineProps<{ codes: string[] }>()
const open = defineModel<boolean>('open', { required: true })
const emit = defineEmits<{ confirmed: [] }>()

const hasSaved = ref(false)
const downloadLink = ref<HTMLAnchorElement | null>(null)

// Prepare codes for clipboard (one per line)
const codesText = computed(() => props.codes.join('\n'))
const { copy, copied } = useClipboard({ source: codesText, copiedDuring: 2000 })

function download() {
  const text = `Backup Codes\n============\n\n${props.codes.join('\n')}\n\nKeep these codes safe. Each code can only be used once.`
  const blob = new Blob([text], { type: 'text/plain' })
  const url = URL.createObjectURL(blob)

  if (downloadLink.value) {
    downloadLink.value.href = url
    downloadLink.value.click()
    URL.revokeObjectURL(url)
  }
}

function handleConfirm() {
  emit('confirmed')
  open.value = false
}
</script>

<template>
  <UModal
    v-model:open="open"
    title="Save your backup codes"
    :close="false"
    :dismissible="false"
  >
    <template #body>
      <UAlert
        color="warning"
        title="Save these codes now"
        description="You won't be able to see them again. Store them in a safe place like a password manager."
      />

      <div class="grid grid-cols-2 gap-2 font-mono">
        <div
          v-for="code in codes"
          :key="code"
        >
          {{ code }}
        </div>
      </div>

      <div class="flex gap-2">
        <UButton @click="copy()">
          {{ copied ? 'Copied!' : 'Copy all' }}
        </UButton>
        <UButton @click="download">Download</UButton>
      </div>

      <UCheckbox
        v-model="hasSaved"
        label="I have saved these backup codes in a safe place"
      />

      <UButton
        :disabled="!hasSaved"
        @click="handleConfirm"
      >
        I've saved my codes
      </UButton>

      <!-- Hidden download link for Vue-compatible file download -->
      <a
        ref="downloadLink"
        download="backup-codes.txt"
        class="hidden"
      />
    </template>
  </UModal>
</template>

The modal is non-dismissible (:close="false" and :dismissible="false"). Users can't close it by clicking outside or pressing escape. They must check the box and click the button. This ensures they've at least seen the codes.

We provide two ways to save: copy to clipboard or download as a file. The clipboard uses VueUse's useClipboard for a clean implementation. The download uses a hidden anchor element trick that works across browsers.

Backup Code Authentication [Backend]

Add the routes:

# config/routes.rb
namespace :backup_code do
  resource :authentication, only: :create
end
# config/routes.rb
namespace :backup_code do
  resource :authentication, only: :create
end
# config/routes.rb
namespace :backup_code do
  resource :authentication, only: :create
end

The controller:

# app/controllers/backup_code/authentications_controller.rb
class BackupCode::AuthenticationsController < ApplicationController
  include PendingAuthentication

  MAX_FAILED_ATTEMPTS = 10
  LOCKOUT_DURATION = 30.minutes

  skip_before_action :authenticate_user!

  rate_limit to: 5, within: 1.minute

  def create
    user = pending_user

    return handle_expired_session unless user
    return handle_locked_out(user) if user.locked_backup_codes_until&.future?

    backup_code = find_and_verify_code(user)

    return handle_invalid_code(user) unless backup_code

    user.update!(failed_backup_code_attempts: 0, locked_backup_codes_until: nil)
    backup_code.mark_as_used! if user.active_for_authentication?
    complete_sign_in(user)
  end

  private

  def handle_expired_session
    session.delete(WebAuthn::SESSION_KEY)
    redirect_to new_user_session_path, alert: 'Session expired. Please sign in again.'
  end

  def handle_locked_out(user)
    minutes_remaining = ((user.locked_backup_codes_until - Time.current) / 60).ceil
    redirect_to new_web_authn_authentication_path,
                alert: "Backup codes temporarily locked. Try again in #{minutes_remaining} minutes."
  end

  def find_and_verify_code(user)
    user.backup_codes.unused.find { it.verify(params[:code]) }
  end

  def handle_invalid_code(user)
    new_attempts = user.failed_backup_code_attempts + 1

    if new_attempts >= MAX_FAILED_ATTEMPTS
      user.update!(failed_backup_code_attempts: 0, locked_backup_codes_until: LOCKOUT_DURATION.from_now)
      redirect_to new_web_authn_authentication_path,
                  alert: "Too many failed attempts. Try again in #{(LOCKOUT_DURATION / 60).to_i} minutes."
    else
      user.update!(failed_backup_code_attempts: new_attempts)
      redirect_to new_web_authn_authentication_path, alert: 'Invalid backup code'
    end
  end
end
# app/controllers/backup_code/authentications_controller.rb
class BackupCode::AuthenticationsController < ApplicationController
  include PendingAuthentication

  MAX_FAILED_ATTEMPTS = 10
  LOCKOUT_DURATION = 30.minutes

  skip_before_action :authenticate_user!

  rate_limit to: 5, within: 1.minute

  def create
    user = pending_user

    return handle_expired_session unless user
    return handle_locked_out(user) if user.locked_backup_codes_until&.future?

    backup_code = find_and_verify_code(user)

    return handle_invalid_code(user) unless backup_code

    user.update!(failed_backup_code_attempts: 0, locked_backup_codes_until: nil)
    backup_code.mark_as_used! if user.active_for_authentication?
    complete_sign_in(user)
  end

  private

  def handle_expired_session
    session.delete(WebAuthn::SESSION_KEY)
    redirect_to new_user_session_path, alert: 'Session expired. Please sign in again.'
  end

  def handle_locked_out(user)
    minutes_remaining = ((user.locked_backup_codes_until - Time.current) / 60).ceil
    redirect_to new_web_authn_authentication_path,
                alert: "Backup codes temporarily locked. Try again in #{minutes_remaining} minutes."
  end

  def find_and_verify_code(user)
    user.backup_codes.unused.find { it.verify(params[:code]) }
  end

  def handle_invalid_code(user)
    new_attempts = user.failed_backup_code_attempts + 1

    if new_attempts >= MAX_FAILED_ATTEMPTS
      user.update!(failed_backup_code_attempts: 0, locked_backup_codes_until: LOCKOUT_DURATION.from_now)
      redirect_to new_web_authn_authentication_path,
                  alert: "Too many failed attempts. Try again in #{(LOCKOUT_DURATION / 60).to_i} minutes."
    else
      user.update!(failed_backup_code_attempts: new_attempts)
      redirect_to new_web_authn_authentication_path, alert: 'Invalid backup code'
    end
  end
end
# app/controllers/backup_code/authentications_controller.rb
class BackupCode::AuthenticationsController < ApplicationController
  include PendingAuthentication

  MAX_FAILED_ATTEMPTS = 10
  LOCKOUT_DURATION = 30.minutes

  skip_before_action :authenticate_user!

  rate_limit to: 5, within: 1.minute

  def create
    user = pending_user

    return handle_expired_session unless user
    return handle_locked_out(user) if user.locked_backup_codes_until&.future?

    backup_code = find_and_verify_code(user)

    return handle_invalid_code(user) unless backup_code

    user.update!(failed_backup_code_attempts: 0, locked_backup_codes_until: nil)
    backup_code.mark_as_used! if user.active_for_authentication?
    complete_sign_in(user)
  end

  private

  def handle_expired_session
    session.delete(WebAuthn::SESSION_KEY)
    redirect_to new_user_session_path, alert: 'Session expired. Please sign in again.'
  end

  def handle_locked_out(user)
    minutes_remaining = ((user.locked_backup_codes_until - Time.current) / 60).ceil
    redirect_to new_web_authn_authentication_path,
                alert: "Backup codes temporarily locked. Try again in #{minutes_remaining} minutes."
  end

  def find_and_verify_code(user)
    user.backup_codes.unused.find { it.verify(params[:code]) }
  end

  def handle_invalid_code(user)
    new_attempts = user.failed_backup_code_attempts + 1

    if new_attempts >= MAX_FAILED_ATTEMPTS
      user.update!(failed_backup_code_attempts: 0, locked_backup_codes_until: LOCKOUT_DURATION.from_now)
      redirect_to new_web_authn_authentication_path,
                  alert: "Too many failed attempts. Try again in #{(LOCKOUT_DURATION / 60).to_i} minutes."
    else
      user.update!(failed_backup_code_attempts: new_attempts)
      redirect_to new_web_authn_authentication_path, alert: 'Invalid backup code'
    end
  end
end

Let's break down the security considerations.

Rate limiting happens at two levels. Rack-level limits (5 requests per minute) prevent request flooding. Application-level limits (10 failed attempts) trigger a 30-minute lockout:

if new_attempts >= MAX_FAILED_ATTEMPTS
  user.update!(
    failed_backup_code_attempts: 0,
    locked_backup_codes_until: LOCKOUT_DURATION.from_now
  )
end
if new_attempts >= MAX_FAILED_ATTEMPTS
  user.update!(
    failed_backup_code_attempts: 0,
    locked_backup_codes_until: LOCKOUT_DURATION.from_now
  )
end
if new_attempts >= MAX_FAILED_ATTEMPTS
  user.update!(
    failed_backup_code_attempts: 0,
    locked_backup_codes_until: LOCKOUT_DURATION.from_now
  )
end

Why reset the counter when locking? Because the lock itself is the punishment. If we kept the counter at 10, the next failed attempt after unlock would immediately lock again.

Code verification iterates through unused codes and checks each one:

user.backup_codes.unused.find { it.verify(params[:code
user.backup_codes.unused.find { it.verify(params[:code
user.backup_codes.unused.find { it.verify(params[:code

This is O(n) where n is at most 10, which is fine. We could add an index on the hash, but BCrypt hashes are salted so we'd need to check each one anyway.

We only mark the code as used after confirming the user can sign in:

backup_code.mark_as_used! if user.active_for_authentication?
backup_code.mark_as_used! if user.active_for_authentication?
backup_code.mark_as_used! if user.active_for_authentication?

This prevents a subtle bug. If a user's account is locked (too many failed password attempts), Devise's active_for_authentication? returns false. Without this check, we'd consume their backup code but not let them in.

What Can Go Wrong

Backup code issues to watch for:

  • BCrypt timing: We hash codes with BCrypt (cost 12), so verification takes ~250ms per code. With 10 codes to check, worst case is 2.5 seconds. Users might think it's broken. Consider showing a loading state.

  • Rate limiting: Without limits, attackers can brute-force codes. We use both Rack-level (5/min per IP) and application-level (10 failures = 30-minute lockout) limits.

  • Code already used: Users sometimes try to reuse a code. Show a clear error message explaining the code has already been used.

  • All codes exhausted: If all codes are used, the user is locked out. Show a warning when codes are running low (e.g., "You have 2 backup codes remaining").

Adding Backup Code Entry to the 2FA Page [Frontend]

Remember the TwoFactorAuth page from Part 2? We need to update it to handle backup code entry. Rather than navigating to a separate page, we add a toggle that lets users switch between passkey authentication and backup code entry on the same page.

First, add the state and form for backup codes:

<script setup lang="ts">
import { useForm } from '@inertiajs/vue3'
import { backup_code_authentication_path } from '~/routes.js'

// ... existing passkey code

// Toggle between passkey and backup code
const useBackupCode = ref(false)

// Backup code form using Inertia's useForm
const backupCodeForm = useForm({
  code: ''
})

function submitBackupCode() {
  error.value = null
  backupCodeForm.post(backup_code_authentication_path())
}

function toggleMethod() {
  useBackupCode.value = !useBackupCode.value
  error.value = null
  backupCodeForm.reset()
}
</script>
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3'
import { backup_code_authentication_path } from '~/routes.js'

// ... existing passkey code

// Toggle between passkey and backup code
const useBackupCode = ref(false)

// Backup code form using Inertia's useForm
const backupCodeForm = useForm({
  code: ''
})

function submitBackupCode() {
  error.value = null
  backupCodeForm.post(backup_code_authentication_path())
}

function toggleMethod() {
  useBackupCode.value = !useBackupCode.value
  error.value = null
  backupCodeForm.reset()
}
</script>
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3'
import { backup_code_authentication_path } from '~/routes.js'

// ... existing passkey code

// Toggle between passkey and backup code
const useBackupCode = ref(false)

// Backup code form using Inertia's useForm
const backupCodeForm = useForm({
  code: ''
})

function submitBackupCode() {
  error.value = null
  backupCodeForm.post(backup_code_authentication_path())
}

function toggleMethod() {
  useBackupCode.value = !useBackupCode.value
  error.value = null
  backupCodeForm.reset()
}
</script>

Notice we use Inertia's useForm for backup codes but fetch() for passkeys. Why the difference? Backup code submission is a simple form post (one field, standard request/response). useForm gives us automatic loading states via backupCodeForm.processing and cleaner error handling. Passkey authentication needs the two-step fetch pattern (get challenge first, then submit response) because of the browser's WebAuthn ceremony between the two requests.

The template uses conditional rendering to switch between views:

<template>
  <AuthLayout>
    <template #card-content>
      <!-- Passkey Authentication -->
      <template v-if="!useBackupCode">
        <!-- ... existing passkey UI ... -->

        <UButton
          :disabled="!isSupported || isAuthenticating"
          :loading="isAuthenticating"
          block
          size="lg"
          @click="authenticate"
        >
          {{ isAuthenticating ? 'Verifying...' : 'Sign in with passkey' }}
        </UButton>

        <USeparator label="OR" />

        <UButton
          variant="link"
          color="neutral"
          block
          @click="toggleMethod"
        >
          Use a backup code
        </UButton>
      </template>

      <!-- Backup Code Entry -->
      <template v-else>
        <AuthHeader
          icon="i-hugeicons-key-01"
          title="Enter backup code"
        />

        <p class="text-muted text-center text-sm">Enter one of your backup codes to sign in.</p>

        <form @submit.prevent="submitBackupCode">
          <UFormField
            label="Backup code"
            required
          >
            <UInput
              v-model="backupCodeForm.code"
              placeholder="XXXXX-XXXXX"
              class="font-mono"
              size="lg"
              autofocus
              :disabled="backupCodeForm.processing"
            />
          </UFormField>

          <UButton
            type="submit"
            :loading="backupCodeForm.processing"
            block
            size="lg"
          >
            {{ backupCodeForm.processing ? 'Verifying...' : 'Sign in with backup code' }}
          </UButton>
        </form>

        <USeparator label="OR" />

        <UButton
          variant="link"
          color="neutral"
          block
          @click="toggleMethod"
        >
          Use passkey instead
        </UButton>
      </template>
    </template>
  </AuthLayout>
</template>
<template>
  <AuthLayout>
    <template #card-content>
      <!-- Passkey Authentication -->
      <template v-if="!useBackupCode">
        <!-- ... existing passkey UI ... -->

        <UButton
          :disabled="!isSupported || isAuthenticating"
          :loading="isAuthenticating"
          block
          size="lg"
          @click="authenticate"
        >
          {{ isAuthenticating ? 'Verifying...' : 'Sign in with passkey' }}
        </UButton>

        <USeparator label="OR" />

        <UButton
          variant="link"
          color="neutral"
          block
          @click="toggleMethod"
        >
          Use a backup code
        </UButton>
      </template>

      <!-- Backup Code Entry -->
      <template v-else>
        <AuthHeader
          icon="i-hugeicons-key-01"
          title="Enter backup code"
        />

        <p class="text-muted text-center text-sm">Enter one of your backup codes to sign in.</p>

        <form @submit.prevent="submitBackupCode">
          <UFormField
            label="Backup code"
            required
          >
            <UInput
              v-model="backupCodeForm.code"
              placeholder="XXXXX-XXXXX"
              class="font-mono"
              size="lg"
              autofocus
              :disabled="backupCodeForm.processing"
            />
          </UFormField>

          <UButton
            type="submit"
            :loading="backupCodeForm.processing"
            block
            size="lg"
          >
            {{ backupCodeForm.processing ? 'Verifying...' : 'Sign in with backup code' }}
          </UButton>
        </form>

        <USeparator label="OR" />

        <UButton
          variant="link"
          color="neutral"
          block
          @click="toggleMethod"
        >
          Use passkey instead
        </UButton>
      </template>
    </template>
  </AuthLayout>
</template>
<template>
  <AuthLayout>
    <template #card-content>
      <!-- Passkey Authentication -->
      <template v-if="!useBackupCode">
        <!-- ... existing passkey UI ... -->

        <UButton
          :disabled="!isSupported || isAuthenticating"
          :loading="isAuthenticating"
          block
          size="lg"
          @click="authenticate"
        >
          {{ isAuthenticating ? 'Verifying...' : 'Sign in with passkey' }}
        </UButton>

        <USeparator label="OR" />

        <UButton
          variant="link"
          color="neutral"
          block
          @click="toggleMethod"
        >
          Use a backup code
        </UButton>
      </template>

      <!-- Backup Code Entry -->
      <template v-else>
        <AuthHeader
          icon="i-hugeicons-key-01"
          title="Enter backup code"
        />

        <p class="text-muted text-center text-sm">Enter one of your backup codes to sign in.</p>

        <form @submit.prevent="submitBackupCode">
          <UFormField
            label="Backup code"
            required
          >
            <UInput
              v-model="backupCodeForm.code"
              placeholder="XXXXX-XXXXX"
              class="font-mono"
              size="lg"
              autofocus
              :disabled="backupCodeForm.processing"
            />
          </UFormField>

          <UButton
            type="submit"
            :loading="backupCodeForm.processing"
            block
            size="lg"
          >
            {{ backupCodeForm.processing ? 'Verifying...' : 'Sign in with backup code' }}
          </UButton>
        </form>

        <USeparator label="OR" />

        <UButton
          variant="link"
          color="neutral"
          block
          @click="toggleMethod"
        >
          Use passkey instead
        </UButton>
      </template>
    </template>
  </AuthLayout>
</template>

This keeps everything on one page. Users don't navigate away to enter a backup code. They click "Use a backup code", enter it, and submit. If they change their mind, they click "Use passkey instead" to go back.

Backup Code Regeneration [Backend]

Users need a way to regenerate codes if they're running low or think they've been compromised. Add the route:

# config/routes.rb
namespace :users do
  resource :backup_codes, only: :create
end
# config/routes.rb
namespace :users do
  resource :backup_codes, only: :create
end
# config/routes.rb
namespace :users do
  resource :backup_codes, only: :create
end

The controller is simple because the service does all the work:

# app/controllers/users/backup_codes_controller.rb
class Users::BackupCodesController < ApplicationController
  rate_limit to: 5, within: 1.minute

  def create
    return head :forbidden unless current_user.web_authn_enabled?

    render json: { backup_codes: BackupCodes::Generator.new(current_user).call }
  end
end
# app/controllers/users/backup_codes_controller.rb
class Users::BackupCodesController < ApplicationController
  rate_limit to: 5, within: 1.minute

  def create
    return head :forbidden unless current_user.web_authn_enabled?

    render json: { backup_codes: BackupCodes::Generator.new(current_user).call }
  end
end
# app/controllers/users/backup_codes_controller.rb
class Users::BackupCodesController < ApplicationController
  rate_limit to: 5, within: 1.minute

  def create
    return head :forbidden unless current_user.web_authn_enabled?

    render json: { backup_codes: BackupCodes::Generator.new(current_user).call }
  end
end

The web_authn_enabled? check prevents users from regenerating backup codes if they don't have any passkeys. This could happen if someone tries to access the endpoint directly.

The frontend shows a confirmation modal (regenerating invalidates existing codes), then displays the new codes:

<script setup lang="ts">
import { ref } from 'vue'
import BackupCodesModal from '~/components/users/BackupCodesModal.vue'
import { users_backup_codes_path } from '~/routes.js'
import { getCsrfToken } from '~/utils/csrf'

defineProps<{ remaining: number }>()
const emit = defineEmits<{ regenerated: [] }>()

const showConfirmModal = ref(false)
const showCodesModal = ref(false)
const generatedCodes = ref<string[]>([])
const isRegenerating = ref(false)
const error = ref<string | null>(null)

async function regenerateCodes() {
  isRegenerating.value = true
  error.value = null

  try {
    const response = await fetch(users_backup_codes_path(), {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': getCsrfToken()
      }
    })

    if (!response.ok) {
      throw new Error('Failed to regenerate backup codes')
    }

    const data = await response.json()
    generatedCodes.value = data.backup_codes

    // Close confirmation, show new codes
    showConfirmModal.value = false
    showCodesModal.value = true
  } catch (err) {
    error.value = err instanceof Error ? err.message : 'An unexpected error occurred'
  } finally {
    isRegenerating.value = false
  }
}

function handleCodesConfirmed() {
  emit('regenerated')
}
</script>

<template>
  <UCard>
    <template #header>
      <div class="flex items-center justify-between">
        <div>
          <h3>Backup codes</h3>
          <p class="text-muted text-sm">Use these one-time codes if you lose access to your passkey</p>
        </div>
        <UButton
          size="sm"
          variant="outline"
          @click="showConfirmModal = true"
        >
          Regenerate
        </UButton>
      </div>
    </template>

    <div class="flex items-center gap-3">
      <div class="bg-muted flex size-10 items-center justify-center rounded-lg">
        <UIcon
          name="i-hugeicons-key-01"
          class="text-muted size-5"
        />
      </div>
      <div>
        <p class="font-medium">{{ remaining }} of 10 codes remaining</p>
        <p class="text-muted text-xs">Each code can only be used once</p>
      </div>
    </div>

    <!-- Confirmation modal -->
    <UModal
      v-model:open="showConfirmModal"
      title="Regenerate backup codes?"
    >
      <template #body>
        <UAlert
          color="warning"
          title="This will invalidate your current codes"
          description="All existing backup codes will be permanently deleted. You'll receive 10 new codes that you must save."
        />

        <div class="mt-6 flex justify-end gap-2">
          <UButton
            variant="outline"
            :disabled="isRegenerating"
            @click="showConfirmModal = false"
          >
            Cancel
          </UButton>
          <UButton
            color="error"
            :loading="isRegenerating"
            @click="regenerateCodes"
          >
            Regenerate codes
          </UButton>
        </div>
      </template>
    </UModal>

    <!-- Show new codes -->
    <BackupCodesModal
      v-model:open="showCodesModal"
      :codes="generatedCodes"
      @confirmed="handleCodesConfirmed"
    />
  </UCard>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import BackupCodesModal from '~/components/users/BackupCodesModal.vue'
import { users_backup_codes_path } from '~/routes.js'
import { getCsrfToken } from '~/utils/csrf'

defineProps<{ remaining: number }>()
const emit = defineEmits<{ regenerated: [] }>()

const showConfirmModal = ref(false)
const showCodesModal = ref(false)
const generatedCodes = ref<string[]>([])
const isRegenerating = ref(false)
const error = ref<string | null>(null)

async function regenerateCodes() {
  isRegenerating.value = true
  error.value = null

  try {
    const response = await fetch(users_backup_codes_path(), {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': getCsrfToken()
      }
    })

    if (!response.ok) {
      throw new Error('Failed to regenerate backup codes')
    }

    const data = await response.json()
    generatedCodes.value = data.backup_codes

    // Close confirmation, show new codes
    showConfirmModal.value = false
    showCodesModal.value = true
  } catch (err) {
    error.value = err instanceof Error ? err.message : 'An unexpected error occurred'
  } finally {
    isRegenerating.value = false
  }
}

function handleCodesConfirmed() {
  emit('regenerated')
}
</script>

<template>
  <UCard>
    <template #header>
      <div class="flex items-center justify-between">
        <div>
          <h3>Backup codes</h3>
          <p class="text-muted text-sm">Use these one-time codes if you lose access to your passkey</p>
        </div>
        <UButton
          size="sm"
          variant="outline"
          @click="showConfirmModal = true"
        >
          Regenerate
        </UButton>
      </div>
    </template>

    <div class="flex items-center gap-3">
      <div class="bg-muted flex size-10 items-center justify-center rounded-lg">
        <UIcon
          name="i-hugeicons-key-01"
          class="text-muted size-5"
        />
      </div>
      <div>
        <p class="font-medium">{{ remaining }} of 10 codes remaining</p>
        <p class="text-muted text-xs">Each code can only be used once</p>
      </div>
    </div>

    <!-- Confirmation modal -->
    <UModal
      v-model:open="showConfirmModal"
      title="Regenerate backup codes?"
    >
      <template #body>
        <UAlert
          color="warning"
          title="This will invalidate your current codes"
          description="All existing backup codes will be permanently deleted. You'll receive 10 new codes that you must save."
        />

        <div class="mt-6 flex justify-end gap-2">
          <UButton
            variant="outline"
            :disabled="isRegenerating"
            @click="showConfirmModal = false"
          >
            Cancel
          </UButton>
          <UButton
            color="error"
            :loading="isRegenerating"
            @click="regenerateCodes"
          >
            Regenerate codes
          </UButton>
        </div>
      </template>
    </UModal>

    <!-- Show new codes -->
    <BackupCodesModal
      v-model:open="showCodesModal"
      :codes="generatedCodes"
      @confirmed="handleCodesConfirmed"
    />
  </UCard>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import BackupCodesModal from '~/components/users/BackupCodesModal.vue'
import { users_backup_codes_path } from '~/routes.js'
import { getCsrfToken } from '~/utils/csrf'

defineProps<{ remaining: number }>()
const emit = defineEmits<{ regenerated: [] }>()

const showConfirmModal = ref(false)
const showCodesModal = ref(false)
const generatedCodes = ref<string[]>([])
const isRegenerating = ref(false)
const error = ref<string | null>(null)

async function regenerateCodes() {
  isRegenerating.value = true
  error.value = null

  try {
    const response = await fetch(users_backup_codes_path(), {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': getCsrfToken()
      }
    })

    if (!response.ok) {
      throw new Error('Failed to regenerate backup codes')
    }

    const data = await response.json()
    generatedCodes.value = data.backup_codes

    // Close confirmation, show new codes
    showConfirmModal.value = false
    showCodesModal.value = true
  } catch (err) {
    error.value = err instanceof Error ? err.message : 'An unexpected error occurred'
  } finally {
    isRegenerating.value = false
  }
}

function handleCodesConfirmed() {
  emit('regenerated')
}
</script>

<template>
  <UCard>
    <template #header>
      <div class="flex items-center justify-between">
        <div>
          <h3>Backup codes</h3>
          <p class="text-muted text-sm">Use these one-time codes if you lose access to your passkey</p>
        </div>
        <UButton
          size="sm"
          variant="outline"
          @click="showConfirmModal = true"
        >
          Regenerate
        </UButton>
      </div>
    </template>

    <div class="flex items-center gap-3">
      <div class="bg-muted flex size-10 items-center justify-center rounded-lg">
        <UIcon
          name="i-hugeicons-key-01"
          class="text-muted size-5"
        />
      </div>
      <div>
        <p class="font-medium">{{ remaining }} of 10 codes remaining</p>
        <p class="text-muted text-xs">Each code can only be used once</p>
      </div>
    </div>

    <!-- Confirmation modal -->
    <UModal
      v-model:open="showConfirmModal"
      title="Regenerate backup codes?"
    >
      <template #body>
        <UAlert
          color="warning"
          title="This will invalidate your current codes"
          description="All existing backup codes will be permanently deleted. You'll receive 10 new codes that you must save."
        />

        <div class="mt-6 flex justify-end gap-2">
          <UButton
            variant="outline"
            :disabled="isRegenerating"
            @click="showConfirmModal = false"
          >
            Cancel
          </UButton>
          <UButton
            color="error"
            :loading="isRegenerating"
            @click="regenerateCodes"
          >
            Regenerate codes
          </UButton>
        </div>
      </template>
    </UModal>

    <!-- Show new codes -->
    <BackupCodesModal
      v-model:open="showCodesModal"
      :codes="generatedCodes"
      @confirmed="handleCodesConfirmed"
    />
  </UCard>
</template>

Auto-Deleting Backup Codes When Last Passkey Is Removed [Backend]

If a user removes their last passkey, we delete their backup codes too:

# app/models/web_authn_credential.rb
class WebAuthnCredential < ApplicationRecord
  belongs_to :user

  after_destroy :clear_backup_codes_if_last_credential

  private

  def clear_backup_codes_if_last_credential
    return if user.web_authn_credentials.exists?

    user.backup_codes.delete_all
    user.update!(web_authn_id: nil)
  end
end
# app/models/web_authn_credential.rb
class WebAuthnCredential < ApplicationRecord
  belongs_to :user

  after_destroy :clear_backup_codes_if_last_credential

  private

  def clear_backup_codes_if_last_credential
    return if user.web_authn_credentials.exists?

    user.backup_codes.delete_all
    user.update!(web_authn_id: nil)
  end
end
# app/models/web_authn_credential.rb
class WebAuthnCredential < ApplicationRecord
  belongs_to :user

  after_destroy :clear_backup_codes_if_last_credential

  private

  def clear_backup_codes_if_last_credential
    return if user.web_authn_credentials.exists?

    user.backup_codes.delete_all
    user.update!(web_authn_id: nil)
  end
end

This prevents a confusing state where users have backup codes but nothing to back up. It also cleans up the web_authn_id since they're no longer using WebAuthn.

User Model Additions [Backend]

Add the association and helper method:

# app/models/user.rb
class User < ApplicationRecord
  has_many :backup_codes, dependent: :delete_all

  def backup_codes_remaining
    backup_codes.unused.count
  end
end
# app/models/user.rb
class User < ApplicationRecord
  has_many :backup_codes, dependent: :delete_all

  def backup_codes_remaining
    backup_codes.unused.count
  end
end
# app/models/user.rb
class User < ApplicationRecord
  has_many :backup_codes, dependent: :delete_all

  def backup_codes_remaining
    backup_codes.unused.count
  end
end

Displaying Backup Codes Count [Backend]

Now that users have backup codes, we should show them how many they have remaining. Update the Security controller:

# app/controllers/users/security_controller.rb
class Users::SecurityController < ApplicationController
  def show
    render inertia: 'users/Security', props: {
      web_authn_credentials: current_user.web_authn_credentials.select(:id, :nickname, :created_at),
      backup_codes_remaining: current_user.backup_codes_remaining
    }
  end
end
# app/controllers/users/security_controller.rb
class Users::SecurityController < ApplicationController
  def show
    render inertia: 'users/Security', props: {
      web_authn_credentials: current_user.web_authn_credentials.select(:id, :nickname, :created_at),
      backup_codes_remaining: current_user.backup_codes_remaining
    }
  end
end
# app/controllers/users/security_controller.rb
class Users::SecurityController < ApplicationController
  def show
    render inertia: 'users/Security', props: {
      web_authn_credentials: current_user.web_authn_credentials.select(:id, :nickname, :created_at),
      backup_codes_remaining: current_user.backup_codes_remaining
    }
  end
end

The backup_codes_remaining prop tells users how many codes they have left. When it gets low, you can show a warning suggesting they regenerate their codes.

Routes Added in This Part

# config/routes.rb
namespace :users do
  resource :backup_codes, only: :create
end

namespace :backup_code do
  resource :authentication, only: :create
end
# config/routes.rb
namespace :users do
  resource :backup_codes, only: :create
end

namespace :backup_code do
  resource :authentication, only: :create
end
# config/routes.rb
namespace :users do
  resource :backup_codes, only: :create
end

namespace :backup_code do
  resource :authentication, only: :create
end

This gives you:

  • POST /users/backup_codes - Regenerate backup codes

  • POST /backup_code/authentication - Authenticate with backup code

What's Next

At this point, you have a complete WebAuthn implementation:

  • Users can register passkeys

  • Users can sign in with passkeys (password-less)

  • Users have backup codes for recovery

  • Users can regenerate backup codes when needed

In Part 4, we'll discuss the design decisions and trade-offs we made, and the race conditions we chose not to handle.

Previous: Part 2 - Authentication: From 2FA to Password-less

Next: Part 4 - Design Decisions and Trade-offs