Skip to content

Email Verification

Email verification confirms that a user owns the email address they registered with. This guide covers the complete flow using createTokenVerifier and createRateLimiter from ideal-auth.

import {
createTokenVerifier,
createHash,
createRateLimiter,
} from 'ideal-auth';
const verificationTokens = createTokenVerifier({
secret: process.env.SESSION_SECRET,
expiryMs: 24 * 60 * 60 * 1000, // 24 hours
});
const hash = createHash();
// Rate limit resend requests: 3 per 15 minutes
const resendLimiter = createRateLimiter({
maxAttempts: 3,
windowMs: 15 * 60 * 1000,
});
  1. User registers and receives a verification email

    After creating the account, generate a verification token and send it via email.

    // POST /api/auth/register
    export async function register(req, res) {
    const { email, password, name } = req.body;
    // Check if the email is already taken
    const existing = await db.user.findUnique({
    where: { email: email.toLowerCase() },
    });
    if (existing) {
    return res.status(400).json({ error: 'Email already registered' });
    }
    // Hash the password and create the user
    const hashedPassword = await hash.make(password);
    const user = await db.user.create({
    data: {
    email: email.toLowerCase(),
    password: hashedPassword,
    name,
    emailVerifiedAt: null,
    },
    });
    // Generate a verification token
    const token = verificationTokens.createToken(String(user.id));
    const verifyUrl = `${process.env.APP_URL}/verify-email/${token}`;
    await sendEmail({
    to: user.email,
    subject: 'Verify your email address',
    text: `Click here to verify your email: ${verifyUrl}\n\nThis link expires in 24 hours.`,
    });
    // Log the user in (they can use the app while unverified, depending on your policy)
    const session = auth();
    await session.login(user);
    return res.json({ message: 'Account created. Please check your email.' });
    }
  2. User clicks the verification link

    // GET /api/auth/verify-email/:token
    export async function verifyEmail(req, res) {
    const { token } = req.params;
    const result = verificationTokens.verifyToken(token);
    if (!result) {
    return res.status(400).json({ error: 'Invalid or expired verification link' });
    }
    const { userId, iatMs } = result;
    const user = await db.user.findUnique({ where: { id: userId } });
    if (!user) {
    return res.status(400).json({ error: 'Invalid verification link' });
    }
    // Already verified
    if (user.emailVerifiedAt) {
    return res.json({ message: 'Email already verified' });
    }
    // Check if token was issued before the email was changed
    // (handles the case where a user changes their email and an old token is used)
    if (user.emailChangedAt && user.emailChangedAt.getTime() >= iatMs) {
    return res.status(400).json({ error: 'This verification link is no longer valid' });
    }
    // Mark as verified
    await db.user.update({
    where: { id: userId },
    data: { emailVerifiedAt: new Date() },
    });
    return res.json({ message: 'Email verified successfully' });
    }
  3. Redirect after verification

    On the client side, redirect the user after successful verification:

    // app/verify-email/[token]/page.tsx (Next.js example)
    import { redirect } from 'next/navigation';
    export default async function VerifyEmailPage({
    params,
    }: {
    params: { token: string };
    }) {
    const res = await fetch(
    `${process.env.APP_URL}/api/auth/verify-email/${params.token}`,
    );
    const data = await res.json();
    if (res.ok) {
    redirect('/dashboard?verified=true');
    }
    return (
    <div>
    <h1>Verification failed</h1>
    <p>{data.error}</p>
    <a href="/resend-verification">Resend verification email</a>
    </div>
    );
    }

You have two options for handling unverified users: block access entirely or allow access with a reminder.

Use middleware to redirect unverified users to a verification prompt page.

middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const PUBLIC_PATHS = [
'/login',
'/register',
'/verify-email',
'/resend-verification',
'/api/auth',
];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow public paths
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
// The actual verification check must happen in a server component
// because middleware cannot query the database.
return NextResponse.next();
}
lib/require-verified.ts
import { auth } from './auth';
import { redirect } from 'next/navigation';
export async function requireVerifiedUser() {
const session = auth();
const user = await session.user();
if (!user) {
redirect('/login');
}
if (!user.emailVerifiedAt) {
redirect('/verify-email');
}
return user;
}
app/dashboard/page.tsx
import { requireVerifiedUser } from '@/lib/require-verified';
export default async function DashboardPage() {
const user = await requireVerifiedUser();
return <div>Welcome, {user.name}</div>;
}

Instead of blocking access, show a persistent banner reminding the user to verify their email.

components/VerificationBanner.tsx
export function VerificationBanner({
user,
}: {
user: { emailVerifiedAt: Date | null };
}) {
if (user.emailVerifiedAt) return null;
return (
<div role="alert" style={{ background: '#fef3cd', padding: '12px' }}>
<p>
Please verify your email address.{' '}
<form action="/api/auth/resend-verification" method="POST" style={{ display: 'inline' }}>
<button type="submit">Resend verification email</button>
</form>
</p>
</div>
);
}

Allow users to request a new verification email, with rate limiting to prevent abuse:

// POST /api/auth/resend-verification
export async function resendVerification(req, res) {
const session = auth();
const user = await session.user();
if (!user) {
return res.status(401).json({ error: 'Not authenticated' });
}
if (user.emailVerifiedAt) {
return res.json({ message: 'Email is already verified' });
}
// Rate limit by user ID
const result = await resendLimiter.attempt(`resend:${user.id}`);
if (!result.allowed) {
return res.status(429).json({
error: 'Too many requests. Try again later.',
retryAfter: result.resetAt,
});
}
const token = verificationTokens.createToken(String(user.id));
const verifyUrl = `${process.env.APP_URL}/verify-email/${token}`;
await sendEmail({
to: user.email,
subject: 'Verify your email address',
text: `Click here to verify your email: ${verifyUrl}\n\nThis link expires in 24 hours.`,
});
return res.json({ message: 'Verification email sent' });
}

When a user changes their email address, reset their verification status and require re-verification:

// POST /api/auth/change-email
export async function changeEmail(req, res) {
const session = auth();
const user = await session.user();
if (!user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const { newEmail, password } = req.body;
// Verify the current password before allowing an email change
const valid = await hash.verify(password, user.password);
if (!valid) {
return res.status(401).json({ error: 'Invalid password' });
}
// Check if the new email is already taken
const existing = await db.user.findUnique({
where: { email: newEmail.toLowerCase() },
});
if (existing) {
return res.status(400).json({ error: 'Email already in use' });
}
// Update the email and reset verification status
await db.user.update({
where: { id: user.id },
data: {
email: newEmail.toLowerCase(),
emailVerifiedAt: null,
emailChangedAt: new Date(),
},
});
// Send a new verification email
const token = verificationTokens.createToken(String(user.id));
const verifyUrl = `${process.env.APP_URL}/verify-email/${token}`;
await sendEmail({
to: newEmail,
subject: 'Verify your new email address',
text: `Click here to verify your new email: ${verifyUrl}\n\nThis link expires in 24 hours.`,
});
return res.json({ message: 'Email updated. Please verify your new address.' });
}
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
email_verified_at TIMESTAMPTZ,
email_changed_at TIMESTAMPTZ
);
  • Use a signed, expiring token (not a random string stored in the database)
  • Place the token in the URL path, not the query string
  • Set a reasonable expiry (24 hours is typical for verification, shorter for email changes)
  • Rate limit resend requests to prevent inbox flooding
  • Invalidate old verification tokens when the email is changed (via emailChangedAt + iatMs check)
  • Require password verification before allowing an email change
  • Always lowercase email addresses before storing and comparing