SaaS Email Verification: Double Opt-In, Token Expiry, Resend Cooldown, and Email Change Flow
Build a complete email verification system for SaaS. Covers double opt-in flow, secure token generation with SHA-256 hashing, 24-hour expiry, resend cooldown, email change verification with old address confirmation, and React email templates.
Email verification is a security baseline β it proves the user controls the address they signed up with, blocks bots from registering with fake addresses, and ensures your transactional emails reach real inboxes. Done wrong (no expiry, no rate limiting, reusable tokens), it's either a denial-of-service vector or a user frustration machine.
This guide covers the full flow: token generation, verification link, resend with cooldown, and the more complex email change flow.
Database Schema
CREATE TABLE email_verifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email TEXT NOT NULL, -- The email being verified (may differ from current)
token_hash TEXT NOT NULL UNIQUE, -- SHA-256 hash; never store plaintext token
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '24 hours'),
used_at TIMESTAMPTZ, -- NULL = not yet used
-- Type distinguishes signup verification from email change
type TEXT NOT NULL DEFAULT 'signup'
CHECK (type IN ('signup', 'email_change')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index for token lookup
CREATE INDEX idx_email_verifications_hash ON email_verifications(token_hash);
-- Index for rate-limiting resend (recent verifications per user)
CREATE INDEX idx_email_verifications_user ON email_verifications(user_id, created_at DESC);
-- Track email on users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS pending_email TEXT; -- For in-progress email changes
Token Generation and Storage
// lib/email-verification/tokens.ts
import crypto from "crypto";
export function generateVerificationToken(): {
token: string; // Sent in the email link β never stored
tokenHash: string; // SHA-256 hash β stored in DB
} {
const token = crypto.randomBytes(32).toString("hex"); // 64 hex chars
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
return { token, tokenHash };
}
export function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
π SaaS MVP in 8 Weeks β Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment β all handled by one senior team.
- Week 1β2: Architecture design + wireframes
- Week 3β6: Core features built + tested
- Week 7β8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
Server Action: Send Verification Email
// app/actions/email-verification.ts
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { generateVerificationToken, hashToken } from "@/lib/email-verification/tokens";
import { sendVerificationEmail } from "@/lib/email/send";
import { revalidatePath } from "next/cache";
const RESEND_COOLDOWN_SECONDS = 60; // 1 minute between resends
const MAX_ACTIVE_TOKENS = 3; // Max unused tokens per user
export async function sendVerification(): Promise<{
success: boolean;
error?: "cooldown" | "already_verified" | "unknown";
retryAfter?: number;
}> {
const session = await auth();
if (!session?.user) return { success: false, error: "unknown" };
const user = await prisma.user.findUniqueOrThrow({
where: { id: session.user.id },
select: { email: true, emailVerifiedAt: true },
});
if (user.emailVerifiedAt) {
return { success: false, error: "already_verified" };
}
// Resend cooldown: check most recent verification sent in last 60s
const recent = await prisma.emailVerification.findFirst({
where: {
userId: session.user.id,
type: "signup",
usedAt: null,
createdAt: { gte: new Date(Date.now() - RESEND_COOLDOWN_SECONDS * 1000) },
},
orderBy: { createdAt: "desc" },
});
if (recent) {
const secondsLeft = Math.ceil(
(recent.createdAt.getTime() + RESEND_COOLDOWN_SECONDS * 1000 - Date.now()) / 1000
);
return { success: false, error: "cooldown", retryAfter: secondsLeft };
}
// Expire old tokens for this user (clean up)
await prisma.emailVerification.updateMany({
where: { userId: session.user.id, type: "signup", usedAt: null },
data: { expiresAt: new Date() }, // Expire immediately
});
// Create new token
const { token, tokenHash } = generateVerificationToken();
await prisma.emailVerification.create({
data: {
userId: session.user.id,
email: user.email,
tokenHash,
type: "signup",
},
});
// Send email
await sendVerificationEmail({
to: user.email,
verificationLink: `${process.env.NEXT_PUBLIC_APP_URL}/verify-email?token=${token}`,
});
revalidatePath("/settings/account");
return { success: true };
}
Verification Route
// app/verify-email/page.tsx
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { hashToken } from "@/lib/email-verification/tokens";
import { auth } from "@/auth";
import { updateSession } from "@/auth";
interface PageProps {
searchParams: { token?: string };
}
export default async function VerifyEmailPage({ searchParams }: PageProps) {
const { token } = searchParams;
if (!token) {
return <VerifyErrorPage message="Invalid verification link." />;
}
const tokenHash = hashToken(token);
const verification = await prisma.emailVerification.findUnique({
where: { tokenHash },
include: { user: { select: { id: true, email: true, emailVerifiedAt: true } } },
});
// Token not found
if (!verification) {
return <VerifyErrorPage message="This verification link is invalid or has already been used." />;
}
// Token expired
if (verification.expiresAt < new Date()) {
return <VerifyErrorPage message="This verification link has expired. Please request a new one." />;
}
// Token already used
if (verification.usedAt) {
return <VerifyErrorPage message="This link has already been used." />;
}
// β
Valid β mark email as verified
await prisma.$transaction([
prisma.emailVerification.update({
where: { id: verification.id },
data: { usedAt: new Date() },
}),
prisma.user.update({
where: { id: verification.userId },
data: {
emailVerifiedAt: new Date(),
...(verification.type === "email_change" && {
email: verification.email, // Apply new email
pendingEmail: null,
}),
},
}),
]);
// Refresh session to include emailVerifiedAt
await updateSession({ emailVerifiedAt: new Date().toISOString() });
redirect("/dashboard?verified=true");
}
π‘ The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments β with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity β you own everything
Email Change Flow
// app/actions/change-email.ts
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { generateVerificationToken } from "@/lib/email-verification/tokens";
import { sendEmailChangeVerification } from "@/lib/email/send";
import { z } from "zod";
const ChangeEmailSchema = z.object({
newEmail: z.string().email().toLowerCase(),
});
export async function requestEmailChange(
input: z.infer<typeof ChangeEmailSchema>
): Promise<{ success: boolean; error?: string }> {
const session = await auth();
if (!session?.user) return { success: false, error: "Unauthorized" };
const parsed = ChangeEmailSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Invalid email address" };
const { newEmail } = parsed.data;
// Check new email not already taken
const existing = await prisma.user.findUnique({ where: { email: newEmail } });
if (existing) return { success: false, error: "This email is already in use" };
const user = await prisma.user.findUniqueOrThrow({
where: { id: session.user.id },
select: { email: true },
});
if (user.email === newEmail) {
return { success: false, error: "New email is the same as current email" };
}
// Expire any pending email_change tokens
await prisma.emailVerification.updateMany({
where: { userId: session.user.id, type: "email_change", usedAt: null },
data: { expiresAt: new Date() },
});
const { token, tokenHash } = generateVerificationToken();
// Store with new email as target
await prisma.emailVerification.create({
data: {
userId: session.user.id,
email: newEmail, // The new email to switch to
tokenHash,
type: "email_change",
},
});
// Update pending_email so user can see the change is in progress
await prisma.user.update({
where: { id: session.user.id },
data: { pendingEmail: newEmail },
});
// Send verification to the NEW address
await sendEmailChangeVerification({
to: newEmail,
currentEmail: user.email,
verificationLink: `${process.env.NEXT_PUBLIC_APP_URL}/verify-email?token=${token}`,
});
return { success: true };
}
React Email Template
// emails/verification.tsx
import {
Html, Head, Body, Container, Section,
Text, Button, Hr, Preview,
} from "@react-email/components";
interface VerificationEmailProps {
verificationLink: string;
userName?: string;
}
export function VerificationEmail({
verificationLink,
userName = "there",
}: VerificationEmailProps) {
return (
<Html>
<Head />
<Preview>Verify your email address β link expires in 24 hours</Preview>
<Body style={{ backgroundColor: "#f9fafb", fontFamily: "system-ui, sans-serif" }}>
<Container style={{ maxWidth: "560px", margin: "40px auto" }}>
<Section style={{ backgroundColor: "#ffffff", borderRadius: "12px", padding: "40px" }}>
<Text style={{ fontSize: "24px", fontWeight: 700, color: "#111827", margin: "0 0 8px" }}>
Verify your email
</Text>
<Text style={{ color: "#6b7280", margin: "0 0 24px" }}>
Hi {userName}, click the button below to verify your email address.
This link expires in 24 hours.
</Text>
<Button
href={verificationLink}
style={{
backgroundColor: "#2563eb",
color: "#ffffff",
padding: "12px 24px",
borderRadius: "8px",
fontWeight: 600,
textDecoration: "none",
display: "inline-block",
}}
>
Verify email address
</Button>
<Hr style={{ borderColor: "#e5e7eb", margin: "24px 0" }} />
<Text style={{ fontSize: "12px", color: "#9ca3af" }}>
If you didn't sign up for this account, you can safely ignore this email.
This link will expire in 24 hours.
</Text>
<Text style={{ fontSize: "12px", color: "#9ca3af", wordBreak: "break-all" }}>
Or copy this link: {verificationLink}
</Text>
</Section>
</Container>
</Body>
</Html>
);
}
Resend UI with Countdown
// components/verify-email-banner.tsx
"use client";
import { useState, useEffect, useTransition } from "react";
import { sendVerification } from "@/app/actions/email-verification";
import { Mail, CheckCircle } from "lucide-react";
export function VerifyEmailBanner({ email }: { email: string }) {
const [sent, setSent] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [isPending, start] = useTransition();
// Countdown timer
useEffect(() => {
if (cooldown <= 0) return;
const interval = setInterval(() => setCooldown((c) => c - 1), 1000);
return () => clearInterval(interval);
}, [cooldown]);
async function handleResend() {
start(async () => {
const result = await sendVerification();
if (result.success) {
setSent(true);
setCooldown(60);
} else if (result.error === "cooldown" && result.retryAfter) {
setCooldown(result.retryAfter);
}
});
}
return (
<div className="bg-amber-50 border border-amber-200 rounded-xl px-4 py-3 flex items-start gap-3">
<Mail className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 text-sm">
<p className="font-semibold text-amber-900">Verify your email address</p>
<p className="text-amber-700 mt-0.5">
We sent a verification link to <strong>{email}</strong>.
Check your inbox and spam folder.
</p>
{sent && (
<p className="text-green-700 mt-1 flex items-center gap-1">
<CheckCircle className="w-4 h-4" />
Verification email sent!
</p>
)}
<button
onClick={handleResend}
disabled={isPending || cooldown > 0}
className="mt-2 text-amber-800 font-semibold text-xs underline disabled:opacity-50 disabled:no-underline"
>
{cooldown > 0
? `Resend in ${cooldown}s`
: isPending
? "Sendingβ¦"
: "Resend verification email"}
</button>
</div>
</div>
);
}
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic signup verification (no resend UI) | 1 dev | 1β2 days | $400β800 |
| Full flow with resend cooldown + React Email | 1β2 devs | 3β4 days | $1,200β2,400 |
| + Email change flow + pending_email UX | 1 dev | + 2 days | $600β1,200 |
See Also
- SaaS Onboarding Flow
- SaaS Audit Logging
- AWS SES Transactional Email
- Next.js Middleware Auth Patterns
- PostgreSQL Triggers and Audit Logging
Working With Viprasol
Email verification done right requires SHA-256 token hashing (never store plaintext), 24-hour expiry, resend cooldown enforced server-side (not just UI), and a two-step email change flow that sends confirmation to the new address. Our team builds complete verification systems with React Email templates, immediate old-token expiry on resend, and pending_email UX so users can see changes in progress.
What we deliver:
email_verificationsschema: token_hash UNIQUE, expires_at 24h, used_at, type signup/email_changegenerateVerificationToken(): 32-byte crypto.randomBytes hex + SHA-256 hash pairsendVerificationServer Action: cooldown check 60s, expire old tokens, create new, send emailVerifyEmailPage: hash token, check expiry/used,$transactionmark used + update userrequestEmailChange: conflict check, expire old email_change tokens, pending_email fieldVerifyEmailBanner: countdown timer useEffect, cooldown state, isPending transition
Talk to our team about your auth system architecture β
Or explore our SaaS development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours β fast.
Free consultation β’ No commitment β’ Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow β automating repetitive tasks, qualifying leads, and responding across every channel your customers use.