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 minutesconst resendLimiter = createRateLimiter({ maxAttempts: 3, windowMs: 15 * 60 * 1000,});Complete flow
Section titled “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/registerexport async function register(req, res) {const { email, password, name } = req.body;// Check if the email is already takenconst 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 userconst hashedPassword = await hash.make(password);const user = await db.user.create({data: {email: email.toLowerCase(),password: hashedPassword,name,emailVerifiedAt: null,},});// Generate a verification tokenconst 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/:tokenexport 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 verifiedif (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 verifiedawait db.user.update({where: { id: userId },data: { emailVerifiedAt: new Date() },});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
Section titled “Access control patterns”You have two options for handling unverified users: block access entirely or allow access with a reminder.
Block access until verified
Section titled “Block access until verified”Use middleware to redirect unverified users to a verification prompt page.
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();}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;}import { requireVerifiedUser } from '@/lib/require-verified';
export default async function DashboardPage() { const user = await requireVerifiedUser(); return <div>Welcome, {user.name}</div>;}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
Section titled “Allow access but show a banner”Instead of blocking access, show a persistent banner reminding the user to verify their email.
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
Section titled “Resending verification emails”Allow users to request a new verification email, with rate limiting to prevent abuse:
// POST /api/auth/resend-verificationexport 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
Section titled “Email change re-verification”When a user changes their email address, reset their verification status and require re-verification:
// POST /api/auth/change-emailexport 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
Section titled “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
Section titled “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
- 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