Passport

Two-Factor Authentication

Implementing two-factor authentication (2FA) with Laravel Passport

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:

nuxt.config.ts
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.

server/api/2fa.post.ts
// 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

middleware/2fa.ts
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

IssueCauseSolution
Invalid 2FA codeIncorrect code or expiredCheck code and time sync
2FA token expiredToken reached expirationRe-authenticate
Missing 2FA endpointConfiguration errorVerify endpoint configuration
2FA not workingServer-side issueCheck 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.