How to implement email verification in Node.js
Email verification confirms that a user owns the email address they registered with, preventing spam accounts and ensuring communication reaches real users.
As the creator of CoreUI with 25 years of backend development experience, I implement email verification in every SaaS application I build to maintain data quality and comply with email regulations.
The secure pattern uses a cryptographically random token stored in the database with an expiry timestamp — when the user clicks the link, you verify the token, check it hasn’t expired, and mark the email as verified.
Never use predictable tokens like user IDs or sequential numbers — always use crypto.randomBytes.
Add verification fields to the user model and create the verification flow.
// prisma/schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
password String
emailVerified Boolean @default(false)
verificationToken String?
verificationExpiresAt DateTime?
createdAt DateTime @default(now())
}
// src/auth/verification.service.js
import crypto from 'crypto'
import { PrismaClient } from '@prisma/client'
import nodemailer from 'nodemailer'
const prisma = new PrismaClient()
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
})
export async function sendVerificationEmail(userId, email) {
const token = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
await prisma.user.update({
where: { id: userId },
data: {
verificationToken: token,
verificationExpiresAt: expiresAt
}
})
const verificationUrl = `${process.env.APP_URL}/auth/verify-email?token=${token}`
await transporter.sendMail({
from: `"My App" <${process.env.SMTP_FROM}>`,
to: email,
subject: 'Verify your email address',
html: `
<h2>Verify your email</h2>
<p>Click the link below to verify your email address. This link expires in 24 hours.</p>
<a href="${verificationUrl}" style="...">Verify Email</a>
<p>If you didn't create an account, you can safely ignore this email.</p>
`
})
}
crypto.randomBytes(32) generates 32 bytes of cryptographically secure random data, producing a 64-character hex token. This is unpredictable and cannot be guessed by brute force in any reasonable time.
Verification Endpoint
Validate the token and mark the email as verified.
// src/auth/auth.router.js
import { Router } from 'express'
import { sendVerificationEmail } from './verification.service.js'
const router = Router()
router.post('/register', async (req, res, next) => {
try {
const { email, password } = req.body
const hashedPassword = await bcrypt.hash(password, 12)
const user = await prisma.user.create({
data: { email, password: hashedPassword }
})
// Send verification email asynchronously
sendVerificationEmail(user.id, user.email).catch(console.error)
res.status(201).json({ message: 'Account created. Check your email to verify.' })
} catch (err) {
next(err)
}
})
router.get('/verify-email', async (req, res, next) => {
try {
const { token } = req.query
if (!token) return res.status(400).json({ error: 'Token required' })
const user = await prisma.user.findFirst({
where: {
verificationToken: token,
emailVerified: false
}
})
if (!user) {
return res.status(400).json({ error: 'Invalid or already used token' })
}
if (user.verificationExpiresAt && user.verificationExpiresAt < new Date()) {
return res.status(400).json({ error: 'Token expired. Request a new verification email.' })
}
await prisma.user.update({
where: { id: user.id },
data: {
emailVerified: true,
verificationToken: null,
verificationExpiresAt: null
}
})
res.json({ message: 'Email verified successfully. You can now log in.' })
} catch (err) {
next(err)
}
})
Nullifying the token after use prevents replay attacks — the same link cannot be used twice. Checking emailVerified: false ensures tokens from previous registrations can’t be replayed if a user re-registers with the same email.
Resend Verification Email
Allow users to request a new verification email.
router.post('/resend-verification', async (req, res, next) => {
try {
const { email } = req.body
const user = await prisma.user.findFirst({ where: { email, emailVerified: false } })
// Always return success - don't leak whether email exists
if (user) {
await sendVerificationEmail(user.id, user.email)
}
res.json({ message: 'If that email exists, a verification link has been sent.' })
} catch (err) {
next(err)
}
})
The “if that email exists” response prevents account enumeration — an attacker can’t use this endpoint to discover which emails are registered.
Best Practice Note
This is the same email verification pattern used in CoreUI SaaS backend templates. Add rate limiting to the resend endpoint (e.g., one request per 5 minutes per IP) to prevent email flooding. Block unverified users from sensitive actions by checking emailVerified in your auth middleware. See how to implement password reset in Node.js for the companion token flow.



