Skip to main content
RapidDev - Software Development Agency
v0-integrationsNext.js API Route

How to Integrate Duo Security with V0

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.

What you'll learn

  • How to generate a Duo-protected login UI using V0 prompts
  • How to create a Next.js API route that calls the Duo Auth API v2
  • How to securely store Duo credentials as Vercel environment variables
  • How to trigger and verify Duo push notifications or passcode challenges
  • How to issue a session token after successful two-factor verification
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate16 min read45 minutesAuthMarch 2026RapidDev Engineering Team
TL;DR

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

Next.js API Route

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

1

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.

V0 Prompt

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.

2

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.

app/api/auth/duo/route.ts
1// app/api/auth/duo/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import crypto from 'crypto';
4
5const DUO_IKEY = process.env.DUO_INTEGRATION_KEY!;
6const DUO_SKEY = process.env.DUO_SECRET_KEY!;
7const DUO_HOST = process.env.DUO_API_HOSTNAME!;
8
9function 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}
17
18export 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 });
22
23 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;
31
32 const authorization = duoSign('POST', DUO_HOST, path, params);
33 const body = new URLSearchParams(params).toString();
34
35 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 });
44
45 const data = await res.json();
46 if (!res.ok) return NextResponse.json({ error: data.message || 'Duo API error' }, { status: 502 });
47
48 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.

3

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).

app/api/auth/duo/status/route.ts
1// app/api/auth/duo/status/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import crypto from 'crypto';
4
5const DUO_IKEY = process.env.DUO_INTEGRATION_KEY!;
6const DUO_SKEY = process.env.DUO_SECRET_KEY!;
7const DUO_HOST = process.env.DUO_API_HOSTNAME!;
8
9function 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}
17
18export 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 });
22
23 const path = '/auth/v2/auth_status';
24 const params = { txid };
25 const authorization = duoSign('GET', DUO_HOST, path, params);
26
27 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 );
36
37 const data = await res.json();
38 const result = data.response?.result;
39 const status = data.response?.status;
40
41 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.

4

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.

5

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.

V0 Prompt

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

hooks/useDuoVerification.ts
1// hooks/useDuoVerification.ts
2import { useState, useEffect, useRef } from 'react';
3
4export 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);
9
10 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 };
21
22 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 };
31
32 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]);
50
51 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.

V0 Prompt

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.

V0 Prompt

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.

V0 Prompt

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.

typescript
1const date = new Date().toUTCString();
2// Use this same `date` variable in BOTH the canonical string AND the headers
3const 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.

typescript
1useEffect(() => {
2 const id = setInterval(pollStatus, 3000);
3 return () => clearInterval(id); // cleanup on unmount
4}, [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

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.