---
title: "Email Verification"
description: "Verify user email addresses with ideal-auth."
---

> Documentation Index
> Fetch the complete documentation index at: https://ramonmalcolm10.github.io/ideal-auth/llms.txt
> Use this file to discover all available pages before exploring further.

# 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.

## Setup

```ts
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.

```ts
   // 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**

```ts
   // 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:

```tsx
   // 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.

### Next.js

```ts
// 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();
}
```

```ts
// 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;
}
```

```tsx
// 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>;
}
```
### SvelteKit

```ts
// 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.

```tsx
// 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:

```ts
// 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:

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

> **Caution**
>
> Setting `emailChangedAt` ensures that any verification tokens sent to the old email address are invalidated. The verification handler checks `iatMs` against `emailChangedAt` to enforce this.

## Database schema

```sql
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

Source: https://ramonmalcolm10.github.io/ideal-auth/guides/email-verification/index.mdx
