Two-Factor Authentication
Learn how to implement secure two-factor authentication (2FA) using Laravel Passport with Nuxt Umbu.
Overview
Two-factor authentication adds an extra layer of security by requiring users to provide a second form of verification beyond their password. Nuxt Umbu provides built-in support for 2FA flows with Laravel Passport.
2FA Configuration
Configure 2FA endpoints in your Nuxt configuration:
export default defineNuxtConfig({
auth: {
provider: 'passport',
strategies: {
client: {
endpoints: {
twoFactor: {
url: '/api/send-token-2fa',
method: 'POST',
alias: '2fa-token',
property: 'access_token', // Token property name
expires: 'expires_in', // Expiration property name
headerName: '2fa' // Header name for 2FA token
}
}
}
}
}
})
Basic 2FA Implementation
Login with 2FA
<template>
<form @submit.prevent="handleLogin">
<!-- Regular login fields -->
<div>
<label for="email">Email</label>
<input id="email" v-model="credentials.email" type="email" required />
</div>
<div>
<label for="password">Password</label>
<input id="password" v-model="credentials.password" type="password" required />
</div>
<button type="submit" :disabled="loading">
{{ loading ? 'Signing in...' : 'Sign In' }}
</button>
</form>
<!-- 2FA Modal -->
<div v-if="show2FAModal" class="modal">
<h3>Enter 2FA Code</h3>
<form @submit.prevent="handle2FA">
<input
v-model="twoFactorCode"
type="text"
placeholder="Enter 6-digit code"
maxlength="6"
required
/>
<button type="submit" :disabled="twoFactorLoading">
Verify
</button>
</form>
</div>
</template>
<script setup>
const { loginWith, twoFactor } = useAuth()
const credentials = ref({
email: '',
password: ''
})
const show2FAModal = ref(false)
const twoFactorCode = ref('')
const loading = ref(false)
const twoFactorLoading = ref(false)
const handleLogin = async () => {
try {
loading.value = true
await loginWith('client', credentials.value)
// Login successful
} catch (error) {
if (error.statusCode === 423) {
// 2FA required
show2FAModal.value = true
} else {
console.error('Login failed:', error)
}
} finally {
loading.value = false
}
}
const handle2FA = async () => {
try {
twoFactorLoading.value = true
await twoFactor('client', twoFactorCode.value)
show2FAModal.value = false
twoFactorCode.value = ''
// 2FA successful, user is now fully authenticated
} catch (error) {
console.error('2FA failed:', error)
} finally {
twoFactorLoading.value = false
}
}
</script>
Advanced 2FA Flow
2FA State Management
<script setup>
const { loggedIn, user, twoFactor, loginWith } = useAuth()
const authState = ref('login') // 'login', '2fa', 'authenticated'
const twoFactorMethods = ref([]) // Available 2FA methods
const handleLogin = async (credentials) => {
try {
await loginWith('client', credentials)
authState.value = 'authenticated'
} catch (error) {
if (error.statusCode === 423) {
authState.value = '2fa'
twoFactorMethods.value = error.data?.methods || []
} else {
throw error
}
}
}
const handle2FA = async (code, method = 'totp') => {
try {
await twoFactor('client', code)
authState.value = 'authenticated'
} catch (error) {
console.error('2FA verification failed:', error)
}
}
</script>
Multiple 2FA Methods
<template>
<div v-if="authState === '2fa'" class="two-factor-container">
<h3>Two-Factor Authentication</h3>
<!-- Method Selection -->
<div v-if="selectedMethod === null" class="method-selection">
<p>Choose a verification method:</p>
<button
v-for="method in twoFactorMethods"
:key="method.type"
@click="selectedMethod = method.type"
>
{{ method.name }}
</button>
</div>
<!-- TOTP Input -->
<div v-else-if="selectedMethod === 'totp'" class="totp-input">
<p>Enter your 6-digit code:</p>
<input
v-model="totpCode"
type="text"
maxlength="6"
@keyup.enter="verify2FA"
/>
<button @click="verify2FA">Verify</button>
</div>
<!-- SMS Input -->
<div v-else-if="selectedMethod === 'sms'" class="sms-input">
<p>Enter the code sent to your phone:</p>
<input
v-model="smsCode"
type="text"
maxlength="6"
@keyup.enter="verify2FA"
/>
<button @click="verify2FA">Verify</button>
<button @click="resendSMS" class="resend-btn">Resend Code</button>
</div>
</div>
</template>
<script setup>
const selectedMethod = ref(null)
const totpCode = ref('')
const smsCode = ref('')
const verify2FA = async () => {
const code = selectedMethod.value === 'totp' ? totpCode.value : smsCode.value
await handle2FA(code, selectedMethod.value)
}
const resendSMS = async () => {
try {
await $fetch('/api/resend-sms-2fa', {
method: 'POST',
body: { strategy: 'client' }
})
console.log('SMS code resent')
} catch (error) {
console.error('Failed to resend SMS:', error)
}
}
</script>
2FA Token Management
2FA Token Storage
<script setup>
// 2FA tokens are automatically managed by Nuxt Umbu
const { headers } = useAuth()
// Check if 2FA token is present
const has2FAToken = computed(() => {
const headerName = '2fa' // Configured header name
return headers.value.has(headerName)
})
// Manual 2FA token inspection
// Note: 2FA tokens are stored in httpOnly cookies and cannot be accessed directly from JavaScript.
// Cookies are set with the configured prefix: <configured-prefix>_2fa.<strategy>
// and <configured-prefix>_2fa_expiration.<strategy>
// Use the headers composable to check if 2FA token is present
const inspect2FAToken = () => {
const { headers } = useAuth()
const headerName = '2fa' // Configured header name
console.log('2FA Token Present:', headers.value.has(headerName))
// Direct JavaScript access to httpOnly cookies is unavailable for security
}
</script>
2FA Token Expiration
<script setup>
const { $auth } = useAuth()
// 2FA expiration check - follows middleware pattern
// Server-side: cookie extraction via httpOnly cookies
// Client-side: localStorage access only in browser context
const is2FAExpired = computed(() => {
if (!import.meta.client) {
return false // Server handles validation via httpOnly cookies
}
// Client-side: check localStorage for expiration (if mirrored)
const strategy = localStorage.getItem($auth.prefix + 'strategy')
const expiration = strategy
? localStorage.getItem($auth.prefix + '_2fa_expiration.' + strategy)
: null
return expiration ? Date.now() > parseInt(expiration) : true
})
// Handle 2FA expiration
watch(is2FAExpired, (expired) => {
if (expired && loggedIn.value) {
console.log('2FA token expired, user may need to re-authenticate')
// Optionally redirect to re-authentication
}
})
</script>
Server-Side 2FA
2FA API Endpoint
The built-in /api/{twoFactorAlias} handler is automatically registered when 2FA is configured. It uses the configured prefix and cookie options from your auth configuration.
// Custom 2FA endpoint example - matches built-in behavior
export default defineEventHandler(async (event) => {
const { strategyName, code } = await readBody(event)
try {
const { $auth } = useNuxtApp()
const authConfig = $auth.config
// Validate strategy
if (!authConfig.strategies?.[strategyName]?.endpoints?.twoFactor) {
throw createError({
statusCode: 400,
statusMessage: '2FA endpoint not configured for strategy ' + strategyName
})
}
// Get existing auth token
const token = getCookie(event, authConfig.prefix + '_token.' + strategyName)
if (!token) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication token missing'
})
}
const strategy = authConfig.strategies[strategyName]
const twoFactorEndpoint = strategy.endpoints.twoFactor
// Call the configured 2FA endpoint
const response = await $fetch(twoFactorEndpoint.url, {
method: twoFactorEndpoint.method || 'POST',
body: { code },
headers: { Authorization: token }
})
// Extract token and expiration using configured properties
const tokenProperty = twoFactorEndpoint.property || 'access_token'
const expiresProperty = twoFactorEndpoint.expires || 'expires_in'
const twoFactorToken = response[tokenProperty]
const expiresIn = response[expiresProperty]
if (twoFactorToken) {
// Use setTwoFactorCookies with configured prefix and options
// Ensure cookie options include path: '/' for proper cookie scope
const cookieOptions = { ...authConfig.cookieOptions, path: '/' }
setTwoFactorCookies(
event,
strategyName,
twoFactorToken,
expiresIn ? Date.now() + expiresIn * 1000 : 0,
{ ...authConfig, cookieOptions }
)
}
return { success: true, token: twoFactorToken }
} catch (error) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid 2FA code'
})
}
})
Note: The built-in handler is automatically registered at /api/{twoFactorAlias} where the alias comes from your endpoint configuration. Use the twoFactor() composable instead of creating custom endpoints.
2FA Middleware
export default defineNuxtRouteMiddleware(async (to) => {
const { loggedIn, user } = useAuth()
if (!loggedIn.value) {
return navigateTo('/login')
}
// Check if user has 2FA enabled but not verified
if (user.value?.two_factor_enabled && !user.value?.two_factor_verified) {
return navigateTo('/2fa/verify')
}
// Check 2FA token expiration
// Note: 2FA expiration is stored in httpOnly cookies and validated server-side
// Client-side expiration check is not available
if (is2FAExpired.value && user.value?.two_factor_enabled) {
return navigateTo('/2fa/verify')
}
})
2FA Best Practices
Security Considerations
<script setup>
// UX feedback for 2FA attempts
const twoFactorAttempts = ref(0)
const handle2FA = async (code) => {
try {
await twoFactor('client', code)
twoFactorAttempts.value = 0 // Reset on success
} catch (error) {
twoFactorAttempts.value++ // Increment for UX display
// Server enforces rate limiting and account lockout
// Handle server response for lockout scenarios
}
}
</script>
Backup Codes
<script setup>
const backupCodes = ref([])
const showBackupCodes = ref(false)
const generateBackupCodes = async () => {
try {
const response = await $fetch('/api/2fa/backup-codes', {
method: 'POST'
})
backupCodes.value = response.codes
showBackupCodes.value = true
} catch (error) {
console.error('Failed to generate backup codes:', error)
}
}
const useBackupCode = async (code) => {
try {
await twoFactor('client', { code, method: 'backup' })
showBackupCodes.value = false
} catch (error) {
console.error('Invalid backup code:', error)
}
}
</script>
2FA Recovery
<script setup>
const recoveryEmail = ref('')
const showRecovery = ref(false)
const initiateRecovery = async () => {
try {
await $fetch('/api/2fa/recovery', {
method: 'POST',
body: { email: recoveryEmail.value }
})
console.log('Recovery email sent')
} catch (error) {
console.error('Recovery failed:', error)
}
}
</script>
Troubleshooting 2FA
Common Issues
| Issue | Cause | Solution |
|---|---|---|
| Invalid 2FA code | Incorrect code or expired | Check code and time sync |
| 2FA token expired | Token reached expiration | Re-authenticate |
| Missing 2FA endpoint | Configuration error | Verify endpoint configuration |
| 2FA not working | Server-side issue | Check Laravel Passport 2FA setup |
Debug 2FA
<template>
<div class="debug-2fa">
<h3>2FA Debug Info</h3>
<p>Logged In: {{ loggedIn }}</p>
<p>2FA Enabled: {{ user?.two_factor_enabled }}</p>
<p>2FA Verified: {{ user?.two_factor_verified }}</p>
<p>2FA Token Present: {{ has2FAToken }}</p>
<p>2FA Expired: {{ is2FAExpired }}</p>
<button @click="inspectTokens">Inspect Tokens</button>
</div>
</template>
<script setup>
const { loggedIn, user, headers } = useAuth()
const has2FAToken = computed(() => {
return headers.value.has('2fa')
})
const is2FAExpired = computed(() => {
// Follows middleware pattern: server-side cookie extraction, client-side state handling
if (!import.meta.client) {
return false // Server handles validation via httpOnly cookies
}
// Client-side: check localStorage for expiration (if stored)
// Note: 2FA cookies are httpOnly, but expiration may be mirrored in localStorage
const { $auth } = useAuth()
const strategy = localStorage.getItem($auth.prefix + 'strategy')
const expiration = strategy
? localStorage.getItem($auth.prefix + '_2fa_expiration.' + strategy)
: null
return expiration ? Date.now() > parseInt(expiration) : true
})
const inspectTokens = () => {
const { headers, $auth } = useAuth()
// Check token presence via headers (non-sensitive)
console.log('Auth Token Present:', headers.value.has('authorization'))
console.log('2FA Token Present:', headers.value.has('2fa'))
// Check strategy and expiration status (non-sensitive metadata)
if (import.meta.client) {
const strategy = localStorage.getItem($auth.prefix + 'strategy')
console.log('Strategy:', strategy || 'Not set')
if (strategy) {
const expiration = localStorage.getItem($auth.prefix + '_token_expiration.' + strategy)
const twoFactorExpiration = localStorage.getItem($auth.prefix + '_2fa_expiration.' + strategy)
console.log('Auth Token Status:', expiration
? (Date.now() > parseInt(expiration) ? 'Expired' : 'Active')
: 'Unknown')
console.log('2FA Token Status:', twoFactorExpiration
? (Date.now() > parseInt(twoFactorExpiration) ? 'Expired' : 'Active')
: 'Not set')
}
}
// Note: Raw token values are never logged for security
}
</script>
Next: Learn about Middleware for protecting routes and managing authentication flows.