Skip to main content

Authenticator App Enrollment Guide

Allow users to enroll their authenticator apps (like Google Authenticator, Authy, or your own app) to approve login requests.

Overview

Authenticator app enrollment enables users to add their accounts to a mobile authenticator app by scanning a QR code. Once enrolled, the app can:

  1. Generate TOTP codes - Time-based one-time passwords for MFA
  2. Approve push notifications - Okta Verify-style login approval

How Enrollment Works

┌─────────────────┐ 1. User clicks "Add Device" ┌──────────────┐ │ Web App │────────────────────────────────────▶│ ZewstID API │ │ │ │ │ │ Security │ 2. Returns QR code + token │ Generates │ │ Settings │◀────────────────────────────────────│ enrollment │ └─────────────────┘ └──────────────┘ │ 3. User scans QR │ with authenticator ┌─────────────────┐ 4. App registers device ┌──────────────┐ │ Authenticator │────────────────────────────────────▶│ ZewstID API │ │ App │ │ │ │ │ 5. Device enrolled │ Completes │ │ 📱 │◀────────────────────────────────────│ enrollment │ └─────────────────┘ └──────────────┘ │ 6. Web polls for status ┌─────────────────┐ │ Web App │ │ │ │ ✅ Device │ │ enrolled! │ └─────────────────┘

Prerequisites

  • ZewstID application with OAuth client configured
  • @zewstid/nextjs
    SDK v0.5.0 or later
  • Authenticated user session

Quick Start

1. Install Dependencies

npm install @zewstid/nextjs@latest --registry https://npm.zewstid.com/

2. Create Enrollment Page

'use client'; import { useEffect, useState } from 'react'; import { useSession } from 'next-auth/react'; import { useEnrollment, useAuthenticatorDevices } from '@zewstid/nextjs'; export default function AuthenticatorEnrollmentPage() { const { data: session } = useSession(); const [showQR, setShowQR] = useState(false); const { startEnrollment, checkStatus, enrollmentSession, status, isLoading: isEnrolling, error: enrollmentError, } = useEnrollment({ clientId: process.env.NEXT_PUBLIC_ZEWSTID_CLIENT_ID!, accessToken: session?.accessToken, }); const { devices, isLoading: isLoadingDevices, fetchDevices, removeDevice, } = useAuthenticatorDevices({ clientId: process.env.NEXT_PUBLIC_ZEWSTID_CLIENT_ID!, accessToken: session?.accessToken, }); // Start enrollment process const handleAddDevice = async () => { await startEnrollment({ accountName: session?.user?.email || 'My Account', iconUrl: 'https://myapp.com/icon.png', // Optional brand icon }); setShowQR(true); }; // Poll for enrollment completion useEffect(() => { if (!enrollmentSession || status !== 'pending') return; const interval = setInterval(async () => { const currentStatus = await checkStatus(enrollmentSession.enrollmentId); if (currentStatus.status === 'completed') { clearInterval(interval); setShowQR(false); fetchDevices(); // Refresh device list } if (currentStatus.status === 'expired') { clearInterval(interval); setShowQR(false); } }, 2000); return () => clearInterval(interval); }, [enrollmentSession, status]); // Handle device removal const handleRemoveDevice = async (deviceId: string) => { if (confirm('Remove this device? You will need to re-enroll it.')) { await removeDevice(deviceId); } }; if (!session) { return <p>Please sign in to manage authenticator devices.</p>; } return ( <div className="max-w-2xl mx-auto p-6"> <h1 className="text-2xl font-bold mb-6">Authenticator Devices</h1> {/* Enrolled Devices List */} <div className="mb-8"> <h2 className="text-lg font-semibold mb-4">Your Devices</h2> {isLoadingDevices ? ( <p>Loading devices...</p> ) : devices.length === 0 ? ( <p className="text-gray-500"> No devices enrolled. Add one to enable push authentication. </p> ) : ( <ul className="space-y-3"> {devices.map((device) => ( <li key={device.id} className="flex items-center justify-between p-4 bg-gray-50 rounded-lg" > <div> <p className="font-medium">{device.name}</p> <p className="text-sm text-gray-500"> {device.platform}{device.model} </p> <p className="text-xs text-gray-400"> Last active: {new Date(device.lastActive).toLocaleDateString()} </p> </div> <button onClick={() => handleRemoveDevice(device.id)} className="text-red-600 hover:text-red-800" > Remove </button> </li> ))} </ul> )} </div> {/* Add Device Button */} {!showQR && ( <button onClick={handleAddDevice} disabled={isEnrolling} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50" > {isEnrolling ? 'Starting...' : 'Add Authenticator Device'} </button> )} {/* QR Code Modal */} {showQR && enrollmentSession && ( <div className="fixed inset-0 bg-black/50 flex items-center justify-center"> <div className="bg-white p-8 rounded-xl max-w-md w-full"> <h3 className="text-xl font-semibold mb-4"> Scan with Authenticator App </h3> <div className="flex justify-center mb-4"> <img src={enrollmentSession.qrCode} alt="Enrollment QR Code" className="w-64 h-64" /> </div> <p className="text-center text-gray-600 mb-4"> Open your authenticator app and scan this QR code </p> {status === 'pending' && ( <p className="text-center text-blue-600"> Waiting for scan... </p> )} {status === 'completed' && ( <p className="text-center text-green-600"> ✓ Device enrolled successfully! </p> )} {status === 'expired' && ( <p className="text-center text-red-600"> QR code expired. Please try again. </p> )} <button onClick={() => setShowQR(false)} className="w-full mt-4 px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300" > Cancel </button> </div> </div> )} {enrollmentError && ( <p className="mt-4 text-red-600">{enrollmentError}</p> )} </div> ); }

API Reference

useEnrollment(config)

Hook for starting and managing device enrollment sessions.

Config:

{ clientId: string; // Your OAuth client ID accessToken: string; // User's access token apiUrl?: string; // Optional custom API URL }

Returns:

{ // Methods startEnrollment: (options: StartEnrollmentOptions) => Promise<EnrollmentSession>; checkStatus: (enrollmentId: string) => Promise<EnrollmentStatus>; // State enrollmentSession: EnrollmentSession | null; status: 'pending' | 'completed' | 'expired' | null; isLoading: boolean; error: string | null; }

StartEnrollmentOptions:

{ accountName: string; // Display name in authenticator app iconUrl?: string; // Optional brand icon URL }

EnrollmentSession:

{ enrollmentId: string; // Unique session ID qrCode: string; // Data URL for QR code image pairingToken: string; // Token encoded in QR expiresAt: number; // Expiration timestamp (ms) expiresIn: number; // Time until expiry (seconds) }

useAuthenticatorDevices(config)

Hook for listing and managing enrolled devices.

Returns:

{ // Data devices: AuthenticatorDevice[]; // Methods fetchDevices: () => Promise<void>; removeDevice: (deviceId: string) => Promise<void>; // State isLoading: boolean; isRemoving: boolean; error: string | null; }

AuthenticatorDevice:

{ id: string; name: string; platform: 'ios' | 'android'; model: string; registeredAt: string; lastActive?: string; }

Best Practices

1. Show Clear Instructions

Help users understand what they need to do:

<div className="mb-4"> <h3>How to add a device:</h3> <ol className="list-decimal ml-6"> <li>Download an authenticator app (Google Authenticator, Authy, etc.)</li> <li>Click "Add Device" below</li> <li>Scan the QR code with your authenticator app</li> <li>Your device will be automatically registered</li> </ol> </div>

2. Handle Expiration Gracefully

QR codes expire after 5 minutes for security:

{status === 'expired' && ( <div> <p>QR code expired.</p> <button onClick={handleAddDevice}>Generate New QR Code</button> </div> )}

3. Confirm Device Removal

Always confirm before removing devices:

const handleRemove = async (deviceId: string, deviceName: string) => { const confirmed = confirm( `Remove "${deviceName}"? You will need to re-enroll this device to use it again.` ); if (confirmed) { await removeDevice(deviceId); toast.success('Device removed'); } };

4. Show Device Details

Display useful information about each device:

<div> <p className="font-medium">{device.name}</p> <p className="text-sm text-gray-500"> {device.platform === 'ios' ? '🍎 iOS' : '🤖 Android'}{device.model} </p> <p className="text-xs text-gray-400"> Registered: {format(new Date(device.registeredAt), 'MMM d, yyyy')} </p> {device.lastActive && ( <p className="text-xs text-gray-400"> Last used: {formatRelative(new Date(device.lastActive), new Date())} </p> )} </div>

Security Considerations

  1. Enrollment sessions expire - QR codes are valid for 5 minutes only
  2. One-time use - Each QR code can only be scanned once
  3. Authenticated users only - Enrollment requires a valid access token
  4. Device limit - Consider limiting devices per user (e.g., max 5)
  5. Audit logging - Device enrollment/removal is logged for security review

Supported Authenticator Apps

The enrollment QR code is compatible with:

  • ZewstID Authenticator (recommended)
  • Google Authenticator
  • Microsoft Authenticator
  • Authy
  • 1Password
  • Any app supporting the
    zewstid://
    deep link scheme

Troubleshooting

QR code not scanning

  1. Ensure good lighting and camera focus
  2. Try increasing QR code size
  3. Check that the authenticator app is up to date

Device not appearing after scan

  1. Wait a few seconds for the polling to detect the enrollment
  2. Check network connectivity on both web and mobile
  3. Refresh the page and check device list

"Token expired" error

The enrollment session has expired. Generate a new QR code by clicking "Add Device" again.

Next Steps

Was this page helpful?

Let us know how we can improve our documentation