To add Duo Security two-factor authentication to a V0 by Vercel app, generate the login UI with V0, then create a Next.js API route that calls the Duo Auth API to verify the second factor. Store your Duo integration key, secret key, and API hostname as Vercel environment variables. The API route validates the primary credential, triggers a Duo push or passcode check, and only grants access after Duo confirms the second factor.
Add Enterprise-Grade MFA to Your V0 App with Duo Security
Duo Security is the industry-leading multi-factor authentication platform used by thousands of enterprises. Adding Duo to a V0-generated Next.js app gives your users a trusted second factor — a push notification to their phone, a hardware token passcode, or an SMS code — before they access sensitive parts of your application. This is especially valuable for admin dashboards, SaaS products targeting enterprise buyers, and any app that handles financial or medical data.
The integration pattern is straightforward: V0 generates your login form and protected dashboard UI, while a Next.js API route handles the Duo verification handshake entirely on the server. Your Duo integration key, secret key, and API hostname never touch the browser. The API route calls Duo's Auth API v2, waits for the push response, and only returns a success signal to the frontend once Duo confirms the second factor.
Duo separates the MFA concern cleanly from your primary auth system. You can layer Duo on top of any existing authentication — whether that's a username/password check against your database, Auth.js, or Clerk — making it a non-invasive add-on rather than a full auth replacement.
Integration method
Duo Security integrates with V0-generated Next.js apps through a server-side API route that communicates with the Duo Auth API. After a user passes primary authentication, your app calls the Duo API route which triggers a push notification, phone call, or passcode verification to the user's enrolled device. Only once Duo confirms the second factor does the API route issue a session or token.
Prerequisites
- A Duo Security account — sign up at duo.com (free Developer tier available)
- A Duo application created in the Duo Admin Panel with Auth API enabled (note the integration key, secret key, and API hostname)
- Enrolled users in Duo with at least one authentication device (phone with Duo Mobile app, hardware token, etc.)
- A V0 project exported to GitHub and deployed on Vercel
- Basic understanding of Next.js App Router and environment variables
Step-by-step guide
Generate the Login and MFA UI with V0
Generate the Login and MFA UI with V0
Start by generating the login form and MFA verification screen in V0. The UI needs two distinct states: a primary credentials form (email/password) and a Duo verification pending screen that appears after credentials are validated. Use V0's chat to describe both states explicitly so it generates a complete flow. The primary login form should collect email and password, with a submit button that calls your primary auth check. Once primary auth succeeds, the UI transitions to the Duo pending screen which displays a message like 'A Duo push has been sent to your phone' along with a loading spinner and a timeout countdown. The Duo screen should also offer alternative methods — 'Enter a passcode instead' — for users who don't have push access. After V0 generates the components, review the state management. You'll want a state variable like `authStep` that moves through 'credentials' → 'duo_pending' → 'authenticated'. V0 typically generates this correctly if you describe the multi-step flow in your prompt. The key thing to check is that the Duo pending screen polls your API route for the verification result rather than expecting an immediate response — Duo push can take 5-30 seconds for the user to respond.
Create a two-step login page. Step 1: email and password form with a 'Sign In' button. Step 2 (shown after credentials validate): a 'Duo Push Sent' screen with a spinner, message 'A push notification has been sent to your registered device', a countdown timer from 60 seconds, a 'Enter passcode instead' link, and a 'Cancel' button. Step 3: success state that redirects to /dashboard. Use shadcn/ui Card and Button components.
Paste this in V0 chat
Pro tip: Ask V0 to use optimistic UI patterns — show the Duo pending screen immediately when the user clicks Sign In, then validate credentials and trigger the Duo push in parallel to reduce perceived latency.
Expected result: A polished two-step login flow with clear visual transitions between the credentials form and the Duo verification pending state.
Create the Duo API Route for MFA Verification
Create the Duo API Route for MFA Verification
Create a Next.js API route at `app/api/auth/duo/route.ts` that handles the Duo Auth API v2 communication. This route receives the username from the client, constructs a signed request to Duo's `/auth/v2/auth` endpoint, and returns the verification status. Duo's Auth API uses HMAC-SHA1 request signing for authentication — you must include a canonical request string signed with your secret key and base64-encoded as a Basic Auth header. Rather than implementing this from scratch, use the `@duosecurity/duo_web` npm package or the `duo-client-node` package which handles the signing for you. The API route should handle two scenarios: triggering a push (which returns immediately with a `txid` and requires polling) and verifying a passcode (which returns synchronously). For push flows, the route calls `/auth/v2/auth` with `factor: 'push'`, gets back a transaction ID, then polls `/auth/v2/auth_status` until the user approves or denies. To avoid the complexity of server-side polling, have the client poll your `/api/auth/duo/status` endpoint with the `txid`, and have that endpoint check Duo's auth status. This way the 60-second wait happens in a client-controlled polling loop rather than a single long-running serverless function. Always validate that the username passed to this route matches the currently authenticated session — never trust a username from an unauthenticated request.
1// app/api/auth/duo/route.ts2import { NextRequest, NextResponse } from 'next/server';3import crypto from 'crypto';45const DUO_IKEY = process.env.DUO_INTEGRATION_KEY!;6const DUO_SKEY = process.env.DUO_SECRET_KEY!;7const DUO_HOST = process.env.DUO_API_HOSTNAME!;89function duoSign(method: string, host: string, path: string, params: Record<string, string>): string {10 const date = new Date().toUTCString();11 const sortedParams = Object.keys(params).sort().map(k => `${k}=${encodeURIComponent(params[k])}`).join('&');12 const canon = [date, method.toUpperCase(), host.toLowerCase(), path, sortedParams].join('\n');13 const sig = crypto.createHmac('sha1', DUO_SKEY).update(canon).digest('hex');14 const auth = Buffer.from(`${DUO_IKEY}:${sig}`).toString('base64');15 return `Basic ${auth}`;16}1718export async function POST(request: NextRequest) {19 try {20 const { username, factor, passcode } = await request.json();21 if (!username) return NextResponse.json({ error: 'Username required' }, { status: 400 });2223 const path = '/auth/v2/auth';24 const params: Record<string, string> = {25 username,26 factor: factor === 'passcode' ? 'passcode' : 'push',27 device: 'auto',28 async: '1',29 };30 if (factor === 'passcode' && passcode) params.passcode = passcode;3132 const authorization = duoSign('POST', DUO_HOST, path, params);33 const body = new URLSearchParams(params).toString();3435 const res = await fetch(`https://${DUO_HOST}${path}`, {36 method: 'POST',37 headers: {38 Authorization: authorization,39 'Content-Type': 'application/x-www-form-urlencoded',40 Date: new Date().toUTCString(),41 },42 body,43 });4445 const data = await res.json();46 if (!res.ok) return NextResponse.json({ error: data.message || 'Duo API error' }, { status: 502 });4748 return NextResponse.json({ txid: data.response?.txid, stat: data.stat });49 } catch (err) {50 return NextResponse.json({ error: 'Internal server error' }, { status: 500 });51 }52}Pro tip: Use async mode (`async: '1'`) for push requests so the API route returns immediately with a txid. This prevents your serverless function from timing out waiting for user interaction.
Expected result: The API route accepts a POST with username and factor, triggers the Duo push or passcode check, and returns a transaction ID for polling.
Create the Duo Status Polling Endpoint
Create the Duo Status Polling Endpoint
Create a second API route at `app/api/auth/duo/status/route.ts` that the client polls to check whether the user has approved or denied the Duo push. This endpoint accepts the `txid` returned from the previous step and queries Duo's `/auth/v2/auth_status` endpoint. The status endpoint should return one of three states to the client: `waiting` (user hasn't responded yet), `allow` (user approved — proceed to grant access), or `deny` (user denied — show error and restart flow). Duo's API returns these as `result: 'allow'` or `result: 'deny'` with a `status` of `answered`, or `status: 'pushed'` while still waiting. The client should poll this endpoint every 3 seconds until it receives either `allow` or `deny`, or until the 60-second timeout expires. Keep your server-side logic thin here — just proxy the Duo status check and return a normalized response. The heavy lifting of session creation should happen in a separate endpoint only invoked after receiving `allow`. Security note: associate the `txid` with the user's session on the server side so that a client cannot pass an arbitrary txid from a different user's Duo push. For simple implementations, validate that the txid was issued within the last 60 seconds using a short-lived store like Upstash Redis or an in-memory map (not suitable for multi-instance deploys).
1// app/api/auth/duo/status/route.ts2import { NextRequest, NextResponse } from 'next/server';3import crypto from 'crypto';45const DUO_IKEY = process.env.DUO_INTEGRATION_KEY!;6const DUO_SKEY = process.env.DUO_SECRET_KEY!;7const DUO_HOST = process.env.DUO_API_HOSTNAME!;89function duoSign(method: string, host: string, path: string, params: Record<string, string>): string {10 const date = new Date().toUTCString();11 const sortedParams = Object.keys(params).sort().map(k => `${k}=${encodeURIComponent(params[k])}`).join('&');12 const canon = [date, method.toUpperCase(), host.toLowerCase(), path, sortedParams].join('\n');13 const sig = crypto.createHmac('sha1', DUO_SKEY).update(canon).digest('hex');14 const auth = Buffer.from(`${DUO_IKEY}:${sig}`).toString('base64');15 return `Basic ${auth}`;16}1718export async function GET(request: NextRequest) {19 const { searchParams } = new URL(request.url);20 const txid = searchParams.get('txid');21 if (!txid) return NextResponse.json({ error: 'txid required' }, { status: 400 });2223 const path = '/auth/v2/auth_status';24 const params = { txid };25 const authorization = duoSign('GET', DUO_HOST, path, params);2627 const res = await fetch(28 `https://${DUO_HOST}${path}?${new URLSearchParams(params).toString()}`,29 {30 headers: {31 Authorization: authorization,32 Date: new Date().toUTCString(),33 },34 }35 );3637 const data = await res.json();38 const result = data.response?.result;39 const status = data.response?.status;4041 if (result === 'allow') return NextResponse.json({ status: 'allow' });42 if (result === 'deny') return NextResponse.json({ status: 'deny' });43 return NextResponse.json({ status: 'waiting', duoStatus: status });44}Pro tip: Cache the authorization header calculation by factoring out the date string — Duo's signing algorithm requires the same Date header value in both the canonical string and the HTTP request header.
Expected result: A polling endpoint that returns 'allow', 'deny', or 'waiting' based on the Duo push response state.
Configure Vercel Environment Variables
Configure Vercel Environment Variables
Navigate to your Vercel project dashboard, click Settings in the top navigation, then select Environment Variables from the left sidebar. You need to add three environment variables that Duo provides when you create an Auth API application in the Duo Admin Panel. Add `DUO_INTEGRATION_KEY` — this is the Integration Key (IKEY) from your Duo Admin Panel application, a string starting with 'DI'. Add `DUO_SECRET_KEY` — the Secret Key (SKEY), a longer string used for request signing. Both should be set as server-only variables (no `NEXT_PUBLIC_` prefix) since they must never appear in browser code. Add `DUO_API_HOSTNAME` — the API Hostname from your Duo application, in the format `api-xxxxxxxx.duosecurity.com`. Set all three variables for the Production environment. If you have a staging environment, create a separate Duo application in the Duo Admin Panel and use its credentials for Preview deployments — this keeps test traffic separate from production analytics and avoids enrolling test users in production. After adding the environment variables, redeploy your application so the new values are picked up by the serverless functions. Changes to server-side environment variables require a redeployment — they are not hot-reloaded.
Pro tip: Never put DUO_SECRET_KEY in any file committed to GitHub. If you accidentally expose it, immediately rotate the secret in the Duo Admin Panel under Applications → your app → Regenerate Secret Key.
Expected result: Three Duo environment variables visible in Vercel Dashboard → Settings → Environment Variables, with no NEXT_PUBLIC_ prefix on any of them.
Wire Up the Frontend Polling Loop
Wire Up the Frontend Polling Loop
Connect the V0-generated login component to your API routes. After primary credential validation succeeds, the component should call `/api/auth/duo` to trigger the push, then start a polling interval that calls `/api/auth/duo/status` every 3 seconds until the result is `allow` or `deny`. In the component, use a `useEffect` cleanup function to clear the polling interval when the component unmounts — this prevents memory leaks if the user navigates away while waiting. Set a maximum poll count (60 seconds / 3 second interval = 20 polls) and display a timeout error if the user doesn't respond in time. On receiving `allow`, call your session creation endpoint (or set a cookie/JWT), then redirect to the protected route using Next.js router. On `deny`, clear the polling loop and show an error message: 'Duo authentication was denied. Please try again.' Offer a 'Resend Push' button that calls `/api/auth/duo` again with the same username. For the passcode flow, show a text input when the user clicks 'Enter passcode instead'. Call `/api/auth/duo` with `factor: 'passcode'` and the entered code — this returns synchronously with allow/deny rather than requiring polling, so you can await it directly and skip the polling loop for this path. For complex auth flows with session management, RapidDev's team can help configure the full token issuance and refresh logic after Duo verification.
Update the login component to call /api/auth/duo after password validation succeeds. Start polling /api/auth/duo/status every 3 seconds. Show a countdown from 60 seconds. On 'allow' response, redirect to /dashboard. On 'deny', show an error alert. Add a 'Resend Push' button and an 'Enter passcode' toggle that shows a 6-digit input field instead of waiting for push.
Paste this in V0 chat
1// hooks/useDuoVerification.ts2import { useState, useEffect, useRef } from 'react';34export function useDuoVerification() {5 const [status, setStatus] = useState<'idle' | 'pending' | 'allow' | 'deny' | 'timeout'>('idle');6 const [txid, setTxid] = useState<string | null>(null);7 const intervalRef = useRef<NodeJS.Timeout | null>(null);8 const pollCountRef = useRef(0);910 const triggerPush = async (username: string) => {11 setStatus('pending');12 pollCountRef.current = 0;13 const res = await fetch('/api/auth/duo', {14 method: 'POST',15 headers: { 'Content-Type': 'application/json' },16 body: JSON.stringify({ username, factor: 'push' }),17 });18 const data = await res.json();19 if (data.txid) setTxid(data.txid);20 };2122 const verifyPasscode = async (username: string, passcode: string) => {23 const res = await fetch('/api/auth/duo', {24 method: 'POST',25 headers: { 'Content-Type': 'application/json' },26 body: JSON.stringify({ username, factor: 'passcode', passcode }),27 });28 const data = await res.json();29 setStatus(data.status === 'allow' ? 'allow' : 'deny');30 };3132 useEffect(() => {33 if (!txid || status !== 'pending') return;34 intervalRef.current = setInterval(async () => {35 pollCountRef.current += 1;36 if (pollCountRef.current > 20) {37 clearInterval(intervalRef.current!);38 setStatus('timeout');39 return;40 }41 const res = await fetch(`/api/auth/duo/status?txid=${txid}`);42 const data = await res.json();43 if (data.status === 'allow' || data.status === 'deny') {44 clearInterval(intervalRef.current!);45 setStatus(data.status);46 }47 }, 3000);48 return () => clearInterval(intervalRef.current!);49 }, [txid, status]);5051 return { status, triggerPush, verifyPasscode };52}Pro tip: Clear the txid from state after the verification flow completes to prevent replay attacks where a stale txid could be polled again.
Expected result: The login flow correctly transitions through credentials → Duo pending → success/deny states, with the polling loop correctly handling all outcomes.
Common use cases
Admin Dashboard MFA Gate
Protect an admin-only section of your app by requiring Duo push verification before allowing access to the dashboard. After the admin enters their password, the app calls the Duo API route which sends a push to their enrolled phone. The dashboard only renders after Duo confirms approval.
Create an admin login page with email and password fields and a 'Verify with Duo' step that appears after credentials are validated. Show a spinner while waiting for Duo push confirmation. On success, redirect to /admin/dashboard.
Copy this prompt to try it in V0
SaaS App with Enterprise SSO + MFA
For a SaaS product targeting enterprise customers, add Duo as the MFA layer after SSO login. The V0-generated app handles the UI flow — SSO redirect, return callback, then Duo verification — before granting access to the protected application.
Build a login flow that shows an SSO button, then after successful SSO callback shows a 'Two-Factor Verification' screen with a loading state while Duo push is sent. Display success and error states clearly.
Copy this prompt to try it in V0
Sensitive Data Access Confirmation
Require Duo verification before displaying sensitive data like payment details or personal health information, even for already-authenticated users. A 'View Sensitive Data' button triggers a fresh Duo push, adding step-up authentication for high-risk actions.
Create a patient records page where clicking 'View Full Record' triggers a Duo verification modal. Show a countdown timer and a 'Waiting for Duo push...' message. Reveal the record content only after successful verification.
Copy this prompt to try it in V0
Troubleshooting
API returns 401 Unauthorized or 'Invalid signature' from Duo
Cause: The HMAC-SHA1 signing is incorrect — usually caused by a mismatch between the Date header value used in the canonical string and the actual Date header sent in the request, or incorrect URL encoding of parameters.
Solution: Ensure you generate the date string once and use the exact same value in both the canonical string and the HTTP Date header. Parameters in the canonical string must be sorted alphabetically and URL-encoded. Double-check that DUO_INTEGRATION_KEY and DUO_SECRET_KEY are correct in Vercel environment variables.
1const date = new Date().toUTCString();2// Use this same `date` variable in BOTH the canonical string AND the headers3const canon = [date, method, host, path, params].join('\n');4const headers = { Authorization: auth, Date: date };Duo push is never received on the user's device
Cause: The username passed to the Duo API does not match any enrolled user in your Duo Admin Panel, or the user's device is not enrolled.
Solution: Verify the username format matches exactly what was used during Duo enrollment (case-sensitive). Check the Duo Admin Panel → Users to confirm the user exists and has an enrolled device. For testing, use the Duo Mobile app's passcode feature rather than push.
Environment variables are undefined in the API route on Vercel
Cause: The environment variables were added to Vercel but the deployment was not restarted, or the variables have NEXT_PUBLIC_ prefix causing them to be undefined on the server in certain contexts.
Solution: Trigger a fresh deployment from the Vercel Dashboard after adding or changing environment variables. Ensure none of the Duo variables have the NEXT_PUBLIC_ prefix — they are server-only secrets and must not be exposed to the browser.
Polling loop continues after component unmounts, causing 'state update on unmounted component' errors
Cause: The setInterval was not cleared in the useEffect cleanup function when the component unmounted during pending state.
Solution: Return a cleanup function from useEffect that clears the interval. Use a ref to track the interval ID so the cleanup function always references the current interval.
1useEffect(() => {2 const id = setInterval(pollStatus, 3000);3 return () => clearInterval(id); // cleanup on unmount4}, [txid]);Best practices
- Never pass Duo credentials (IKEY, SKEY, API hostname) to the client — all Duo API calls must happen in server-side API routes only
- Use separate Duo applications for Production and Preview/Development environments to keep test traffic isolated
- Implement rate limiting on your /api/auth/duo route to prevent brute-force push spam — limit to 5 push requests per user per 15 minutes
- Associate the txid with the user's session server-side and validate it before accepting an 'allow' response to prevent txid replay attacks
- Always show a 'Use passcode instead' fallback in the UI — push notifications can fail due to network issues or device offline status
- Log Duo authentication events (allow/deny/timeout) in your application's audit log for compliance and security monitoring
- Set a reasonable push expiry (60 seconds) in the UI and instruct users to respond promptly — expired Duo pushes cannot be approved after the timeout
Alternatives
Okta is a full identity provider offering SSO, user management, and built-in MFA — choose Okta if you need a complete identity platform rather than just adding MFA on top of existing auth.
Firebase Auth includes built-in phone number MFA for free — choose Firebase if you want a fully managed auth solution with MFA included rather than a dedicated MFA add-on.
Auth0 has native MFA support built into its authentication flows with no custom API routes required — choose Auth0 if you prefer a managed auth platform with MFA rather than Duo's standalone approach.
Frequently asked questions
Does Duo Security have a free tier for developers?
Yes, Duo offers a free Developer account that supports up to 10 users and includes all Auth API features. This is more than sufficient for building and testing your V0 integration. Sign up at duo.com and create an Auth API application in the Admin Panel to get your integration key, secret key, and API hostname.
Can I use Duo with any primary authentication system?
Yes. Duo acts as a second-factor layer independent of your primary auth. You can use it alongside username/password, Auth.js, Clerk, Auth0, or any other primary authentication method. Duo only handles the second factor — it receives a username, verifies via push or passcode, and returns allow/deny. Your primary auth system handles password validation and session management.
What happens if a user doesn't have the Duo Mobile app installed?
Users can authenticate via SMS passcode, hardware token, or phone callback as alternatives to the push notification. Make sure to enable these factors in the Duo Admin Panel under your application's settings. In your V0 UI, always include a 'Use passcode instead' option that accepts the 6-digit code from any Duo-supported factor.
Is Duo suitable for customer-facing apps or just internal tools?
Duo can be used for both, but it requires users to enroll their devices upfront through Duo's enrollment flow. For consumer apps with casual users, this friction is often too high — consider a simpler OTP solution instead. Duo excels for internal tools, admin dashboards, SaaS products with power users, and any enterprise-facing application where users expect to set up MFA once and use it regularly.
How do I test Duo integration without a real mobile device?
Duo Mobile app includes a passcode generator that works offline — you can use this passcode flow in your API route testing without needing a push notification. Alternatively, Duo's Admin Panel has a 'Bypass codes' feature for users that generates single-use codes. For automated testing, Duo provides a 'Test environment' flag that can be enabled for development applications.
Can Duo push notifications be sent to multiple devices?
Yes, when a user has multiple devices enrolled in Duo, you can specify `device: 'auto'` in the API call (which Duo selects automatically) or let users choose their device. The `device` parameter also accepts specific device IDs retrieved from the Duo Admin API if you want to present a device selection UI.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation