Skip to content

Email Verification

Verify user email addresses with ideal-auth.

Updated View as Markdown

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

  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;
    
      // 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' });
    }
  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>
      );
    }

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 + iatMs check)
  • Require password verification before allowing an email change
  • Always lowercase email addresses before storing and comparing
Navigation

Type to search…

↑↓ navigate↵ selectEsc close