Better Auth ships a twoFactor plugin with TOTP, OTP, and backup codes. It requires a database-backed setup (e.g., NuxtHub).
import { defineServerAuth } from '@onmax/nuxt-better-auth/config'
import { twoFactor } from 'better-auth/plugins/two-factor'
export default defineServerAuth({
emailAndPassword: { enabled: true },
plugins: [
twoFactor({
// Optional: customize issuer shown in authenticator apps
totpOptions: { issuer: 'My Nuxt App' },
}),
],
})
import { navigateTo } from '#imports'
import { defineClientAuth } from '@onmax/nuxt-better-auth/config'
import { twoFactorClient } from 'better-auth/client/plugins'
export default defineClientAuth({
plugins: [
twoFactorClient({
onTwoFactorRedirect() {
navigateTo('/two-factor')
},
}),
],
})
<script setup lang="ts">
definePageMeta({ auth: 'user' })
const { client } = useUserSession()
const password = ref('')
const qr = ref<string | null>(null)
const backupCodes = ref<string[] | null>(null)
const error = ref('')
async function enable2fa() {
try {
const res = await client?.twoFactor.enable({ password: password.value })
if (res?.error) {
error.value = res.error.message
return
}
qr.value = res?.totpURI || null
backupCodes.value = res?.backupCodes || null
} catch (e) {
error.value = 'Failed to enable 2FA. Please try again.'
}
}
</script>
<template>
<form @submit.prevent="enable2fa">
<input v-model="password" type="password" placeholder="Password" />
<button type="submit">Enable 2FA</button>
</form>
<div v-if="qr">
<p>Scan this in your authenticator app:</p>
<pre>{{ qr }}</pre>
</div>
<div v-if="backupCodes">
<p>Backup codes (store securely):</p>
<ul>
<li v-for="code in backupCodes" :key="code">{{ code }}</li>
</ul>
</div>
</template>
Install a QR code library:
pnpm add qrcode
Render the QR code:
<script setup>
import QRCode from 'qrcode'
const qrImage = ref('')
watch(qr, async (uri) => {
if (uri) {
qrImage.value = await QRCode.toDataURL(uri)
}
})
</script>
<template>
<img v-if="qrImage" :src="qrImage" alt="Scan with authenticator app" />
</template>
When a user signs in and 2FA is required, twoFactorClient triggers onTwoFactorRedirect. On your /two-factor page, prompt for the code and call:
await client?.twoFactor.verifyTotp({
code,
trustDevice: true, // optional
})
Backup codes can be verified via:
await client?.twoFactor.verifyBackupCode({ code })
await client?.twoFactor.disable({ password })
Display backup codes to users during 2FA setup. Each code can only be used once.
<template>
<div v-if="backupCodes.length">
<p>Save these backup codes in a secure location:</p>
<ul>
<li v-for="code in backupCodes" :key="code">{{ code }}</li>
</ul>
</div>
</template>
For users who lose their device and backup codes, implement an admin recovery flow:
export default defineEventHandler(async (event) => {
await requireUserSession(event, { user: { role: 'admin' } })
const { userId } = await readBody(event)
const auth = serverAuth(event)
// Reset 2FA for user (requires admin plugin)
await auth.api.admin.disableTwoFactor({ userId })
return { success: true }
})