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.
Setup
import {
createTokenVerifier,
createHash,
createRateLimiter,
} from 'ideal-auth';
const verificationTokens = createTokenVerifier({
secret: process.env.IDEAL_AUTH_SECRET!,
purpose: 'email-verification', // tokens from other flows never verify here
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,
});Complete flow
-
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.' }); } -
User clicks the verification link
// GET /api/auth/verify-email/:token export async function verifyEmail(req, res) { const { token } = req.params; // Rate limit to prevent flooding the endpoint with invalid tokens const ip = req.headers['x-forwarded-for'] ?? req.ip ?? '127.0.0.1'; const { allowed } = await resendLimiter.attempt(`verify:${ip}`); if (!allowed) { return res.status(429).json({ error: 'Too many requests. Try again later.' }); } 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 const updatedUser = await db.user.update({ where: { id: userId }, data: { emailVerifiedAt: new Date() }, }); // Login (or refresh session) so emailVerifiedAt is up to date const session = auth(); await session.login(updatedUser); return res.json({ message: 'Email verified successfully' }); } -
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> ); }
Access control patterns
You have two options for handling unverified users: block access entirely or allow access with a reminder.
Block access until verified
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>;
}// src/hooks.server.ts
import { redirect } from '@sveltejs/kit';
import type { Handle } from '@sveltejs/kit';
import { auth } from '$lib/auth';
const PUBLIC_PATHS = [
'/login',
'/register',
'/verify-email',
'/resend-verification',
'/api/auth',
];
export const handle: Handle = async ({ event, resolve }) => {
const { pathname } = event.url;
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return resolve(event);
}
const session = auth();
const user = await session.user();
if (user && !user.emailVerifiedAt) {
throw redirect(302, '/verify-email');
}
event.locals.user = user;
return resolve(event);
};Allow access but show a banner
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>
);
}Resending verification emails
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' });
}Email change re-verification
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.' });
}Database schema
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
email_verified_at TIMESTAMPTZ,
email_changed_at TIMESTAMPTZ
);Security checklist
- 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
- Rate limit the verification endpoint to prevent CPU exhaustion from HMAC computations on invalid tokens
- Invalidate old verification tokens when the email is changed (via
emailChangedAt+iatMscheck) - Require password verification before allowing an email change
- Always lowercase email addresses before storing and comparing