Back to Blog

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.

Viprasol Tech Team
May 21, 2027
12 min read

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

ScopeTeamTimelineCost Range
Basic signup verification (no resend UI)1 dev1–2 days$400–800
Full flow with resend cooldown + React Email1–2 devs3–4 days$1,200–2,400
+ Email change flow + pending_email UX1 dev+ 2 days$600–1,200

See Also


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_verifications schema: token_hash UNIQUE, expires_at 24h, used_at, type signup/email_change
  • generateVerificationToken(): 32-byte crypto.randomBytes hex + SHA-256 hash pair
  • sendVerification Server Action: cooldown check 60s, expire old tokens, create new, send email
  • VerifyEmailPage: hash token, check expiry/used, $transaction mark used + update user
  • requestEmailChange: conflict check, expire old email_change tokens, pending_email field
  • VerifyEmailBanner: countdown timer useEffect, cooldown state, isPending transition

Talk to our team about your auth system architecture β†’

Or explore our SaaS development services.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours β€” fast.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· AI Agent Systems

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.