Implementing 2FA with email verification codes
Two-factor email authentication sends a 6-digit verification code to the user's email address, providing an additional layer of security beyond traditional email magic links.
The twofa provider generates cryptographically secure 6-digit codes that expire after a short time (5 minutes by default). Users receive the code via email and must enter it to complete authentication.
crypto.randomBytes for code generationimport { twofa } from "@warpy-auth-sdk/core";
const provider = twofa({
from: "noreply@yourdomain.com",
service: {
type: "resend",
apiKey: process.env.RESEND_API_KEY!,
},
});import { twofa } from "@warpy-auth-sdk/core";
const provider = twofa({
from: "noreply@yourdomain.com",
service: {
type: "nodemailer",
server: "smtp.gmail.com:587",
auth: {
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASS!,
},
},
});interface TwoFactorProviderOptions {
// Required: sender email address
from: string;
// Required: email service configuration
service: EmailServiceConfig;
// Optional: custom React Email template
template?: CustomTwoFactorTemplate;
// Optional: app name for email template (default: "Your App")
appName?: string;
// Optional: company name for email template (default: "Your Company")
companyName?: string;
// Optional: code expiration in minutes (default: 5)
expirationMinutes?: number;
}The 2FA flow consists of two steps:
import { authenticate } from "@warpy-auth-sdk/core";
// User provides email
const sendRequest = new Request(
"https://yourdomain.com/auth?email=user@example.com"
);
const result = await authenticate(config, sendRequest);
// result.redirectUrl contains the identifier for code verification
// Example: /auth?identifier=abc123...// User provides the 6-digit code from their email
const verifyRequest = new Request(
"https://yourdomain.com/auth?identifier=abc123...&code=123456"
);
const result = await authenticate(config, verifyRequest);
// result.session contains the authenticated session// proxy.ts
import { authMiddleware } from "@warpy-auth-sdk/core/next";
import { twofa } from "@warpy-auth-sdk/core";
const handler = authMiddleware(
{
secret: process.env.AUTH_SECRET!,
provider: twofa({
from: process.env.EMAIL_FROM!,
service: {
type: "resend",
apiKey: process.env.RESEND_API_KEY!,
},
expirationMinutes: 5,
}),
},
{
basePath: "/api/auth",
successRedirect: "/dashboard",
errorRedirect: "/login",
}
);
export function proxy(request: NextRequest) {
const p = request.nextUrl.pathname;
if (p.startsWith("/api/auth")) return handler(request);
return NextResponse.next();
}GET /api/auth/signin/twofa?email=user@example.com — Send verification codeGET /api/auth/signin/twofa?identifier=xxx&code=123456 — Verify code and sign in"use client";
import { useState } from "react";
export default function TwoFactorLogin() {
const [step, setStep] = useState<"email" | "code">("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [identifier, setIdentifier] = useState("");
const handleSendCode = async () => {
const response = await fetch(
`/api/auth/signin/twofa?email=${encodeURIComponent(email)}`
);
// Extract identifier from redirect URL
const url = new URL(response.url);
const id = url.searchParams.get("identifier");
setIdentifier(id!);
setStep("code");
};
const handleVerifyCode = async () => {
await fetch(
`/api/auth/signin/twofa?identifier=${identifier}&code=${code}`
);
// User is now authenticated, redirect to dashboard
window.location.href = "/dashboard";
};
return step === "email" ? (
<EmailInput onSubmit={handleSendCode} />
) : (
<CodeInput onSubmit={handleVerifyCode} />
);
}The SDK includes a beautiful default email template:
Create your own template using React Email:
import * as React from "react";
import { Html, Body, Container, Text } from "@react-email/components";
function MyTwoFactorEmail({ code }: { code: string }) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif" }}>
<Container>
<Text style={{ fontSize: "24px", fontWeight: "bold" }}>
Your Verification Code
</Text>
<Text
style={{
fontSize: "48px",
fontWeight: "bold",
letterSpacing: "8px",
color: "#0070f3",
}}
>
{code}
</Text>
<Text>This code will expire in 5 minutes.</Text>
</Container>
</Body>
</Html>
);
}
// Use in provider configuration
twofa({
from: "noreply@yourdomain.com",
service: { /* ... */ },
template: {
component: MyTwoFactorEmail,
subject: "Your verification code",
},
});crypto.randomBytes()Development: In-memory Map storage (suitable for single-server)
Production: Use Redis or database for distributed systems:
// Example with Redis
import { createClient } from "redis";
const redis = createClient();
export async function createTwoFactorCode(email: string) {
const code = generateTwoFactorCode();
const identifier = generateSecureToken();
await redis.set(
`2fa:${identifier}`,
JSON.stringify({ email, code }),
"EX",
300 // 5 minutes
);
return { identifier, code };
}twofa({
from: "noreply@yourdomain.com",
service: { /* ... */ },
expirationMinutes: 10, // 10 minutes instead of 5
});twofa({
from: "noreply@yourdomain.com",
service: { /* ... */ },
appName: "My Secure App",
companyName: "My Company Inc.",
});You can configure different services for different environments:
twofa({
from: process.env.EMAIL_FROM!,
service:
process.env.NODE_ENV === "production"
? {
type: "resend",
apiKey: process.env.RESEND_API_KEY!,
}
: {
type: "nodemailer",
server: "localhost:1025", // Local mail server for dev
auth: { user: "", pass: "" },
},
});When using Next.js middleware, these endpoints are automatically created:
GET /api/auth/signin/twofa?email=user@example.com - Send verification codeGET /api/auth/signin/twofa?identifier=xxx&code=123456 - Verify codeGET /api/auth/session - Get current sessionPOST /api/auth/signout - Sign outconst result = await authenticate(config, request);
if (result.error) {
switch (result.error) {
case "Invalid or expired verification code":
// Code is wrong or has expired
break;
case "Email, identifier, or code required":
// Missing required parameters
break;
default:
// Other authentication errors
break;
}
}