Authentication & Email Verification
The secure core: register with a hashed password, verify the email before access, log in to receive a JWT Bearer token, and lock the product routes behind that token.
How Auth Works Here
No third-party auth service — just three trustworthy libraries. This makes the flow explicit and identical across every stack.
Database calls — pick your tab
Each backend section below has a Database sub-tab — choose PostgreSQL (TypeORM) or MongoDB (Mongoose) and the snippet swaps to match. The two query styles compare like this:
Validation Rules & the Mailer
Two small files every handler reuses: express-validator rules that reject bad input, and a Nodemailer transport that sends email.
import { body } from 'express-validator'
export const registerValidation = [
body('name').trim().notEmpty().withMessage('Name is required'),
body('email').isEmail().normalizeEmail().withMessage('Valid email required'),
body('password').isLength({ min: 8 }).withMessage('Password must be at least 8 characters'),
]
export const loginValidation = [
body('email').isEmail().normalizeEmail().withMessage('Valid email required'),
body('password').notEmpty().withMessage('Password is required'),
]
// Applied in routes: router.post('/register', registerValidation, register)
// For NestJS: use class-validator DTOs with a global ValidationPipe insteadimport nodemailer from 'nodemailer'
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: Number(process.env.EMAIL_PORT) || 2525,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
})
export const sendVerificationEmail = async (to, name, token) => {
const verifyUrl = `${process.env.CLIENT_URL}/verify-email/${token}`
await transporter.sendMail({
from: process.env.EMAIL_FROM,
to,
subject: 'Verify your Inventory Express account',
html: `<p>Hello ${name}! <a href="${verifyUrl}">Click here to verify your email</a>. Link expires in 24 hours.</p>`,
})
}sendVerificationEmail(email, name, token) builds a URL like ${CLIENT_URL}/verify-email/${token} and sends it. The client app handles the /verify-email/:token route, which calls GET /api/auth/verify/:token on the backend.
Registration + Verification Email
Hash the password, save an unverified user, then email a crypto-random link that proves they own the address.
import crypto from 'crypto'
import { validationResult } from 'express-validator'
import User from '../models/User.js'
import { sendVerificationEmail } from '../services/emailService.js'
export const register = async (req, res, next) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({ message: 'Validation failed', errors: errors.mapped() })
}
const { name, email, password } = req.body
const existing = await User.findOne({ email })
if (existing) {
return res.status(409).json({ message: 'Email already registered' })
}
const verificationToken = crypto.randomBytes(32).toString('hex')
const verificationTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000)
// The User model hashes the password in a pre-save hook
await User.create({ name, email, password, verificationToken, verificationTokenExpiry })
// Fire-and-forget — don't let email failure block the response
sendVerificationEmail(email, name, verificationToken).catch(console.error)
res.status(201).json({ message: 'Registration successful! Check your email to verify your account.' })
} catch (err) {
next(err)
}
}Here it hits the backend verify route directly. You could instead point it at a frontend page (CLIENT_URL/verify-email/:token) that calls the API — useful if you want a branded “verified!” screen.
Verify the Email Link
When the user clicks the link, match the stored verificationToken (before it expires), flip isVerified to true, and let them log in.
export const verifyEmail = async (req, res, next) => {
try {
const { token } = req.params // route: GET /api/auth/verify/:token
const user = await User.findOne({
verificationToken: token,
verificationTokenExpiry: { $gt: Date.now() },
})
if (!user) {
return res.status(400).json({ message: 'Invalid or expired verification link' })
}
user.isVerified = true
user.verificationToken = undefined
user.verificationTokenExpiry = undefined
await user.save()
res.status(200).json({ message: 'Email verified successfully. You can now log in.' })
} catch (err) {
next(err)
}
}Login + Bearer Token
Check the password, refuse unverified accounts, then sign one JWT and return it. The client stores it and sends it as an Authorization: Bearer header on every request.
Login returns a single signed token. The client attaches it as Authorization: Bearer <token> on every protected request. There is no refresh token and no cookie — one short, signed JWT (default JWT_EXPIRES_IN=7d) is the whole session. Store it in memory or localStorage on the client.
const signToken = (id) =>
jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN || '7d' })
export const login = async (req, res, next) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({ message: 'Validation failed', errors: errors.mapped() })
}
const { email, password } = req.body
const user = await User.findOne({ email })
if (!user || !(await user.comparePassword(password))) {
return res.status(401).json({ message: 'Invalid email or password' })
}
if (!user.isVerified) {
return res.status(403).json({ message: 'Please verify your email before logging in' })
}
const token = signToken(user._id)
res.status(200).json({
token, // client stores this and sends as: Authorization: Bearer <token>
user: { _id: user._id, name: user.name, email: user.email, isVerified: user.isVerified },
})
} catch (err) {
next(err)
}
}Protect the Routes
A small gate that reads the Bearer token, verifies the JWT, and attaches the user. Put it in front of every product route.
import jwt from 'jsonwebtoken'
import User from '../models/User.js'
export const protect = async (req, res, next) => {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ message: 'No token provided' })
}
try {
const token = authHeader.split(' ')[1]
const decoded = jwt.verify(token, process.env.JWT_SECRET)
req.user = await User.findById(decoded.id).select('-password')
if (!req.user) {
return res.status(401).json({ message: 'User not found' })
}
next()
} catch {
res.status(401).json({ message: 'Invalid or expired token' })
}
}
// mount it: app.use('/api/products', protect, productRouter)If you protect pages with Next.js middleware.ts (which runs on the Edge runtime), the jsonwebtokenpackage won’t work there — use jose (jwtVerify) instead. Inside normal Route Handlers and Server Components, jsonwebtoken is fine.
The Register & Login Forms
The UI is just forms that POST to the API. Register kicks off email verification; login stores the returned Bearer token and attaches it as an Authorization header on later requests.
Register form
"use client";
import { useState } from "react";
export default function RegisterPage() {
const [form, setForm] = useState({ name: "", email: "", password: "" });
const [done, setDone] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
const res = await fetch("http://localhost:5000/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) return setError((await res.json()).message);
setDone(true); // account created but unverified — no token yet
}
if (done) return <p>Check your email to verify your account, then log in.</p>;
return (
<form onSubmit={handleSubmit}>
<input value={form.name} placeholder="Full name"
onChange={(e) => setForm({ ...form, name: e.target.value })} />
<input value={form.email} placeholder="Email"
onChange={(e) => setForm({ ...form, email: e.target.value })} />
<input type="password" value={form.password} placeholder="Password (min 8)"
onChange={(e) => setForm({ ...form, password: e.target.value })} />
{error && <p className="text-red-500">{error}</p>}
<button type="submit">Create account</button>
</form>
);
}Login helper
async function login(email: string, password: string) {
const res = await fetch("http://localhost:5000/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error((await res.json()).message);
const { token, user } = await res.json(); // ONE Bearer token
localStorage.setItem("token", token); // send as: Authorization: Bearer <token>
return user; // keep the user object in state
}After login, the server returns a JWT token and a user object. Calllogin(token, user) from AuthContext — it saves the token to localStorage and sets the user in React state. The GET /api/auth/me route (guarded by protect()) lets the app restore the session on page refresh by re-fetching the user with the stored token.
- ✓Register creates an unverified user and a verification email lands in Mailtrap
- ✓Clicking the link calls
GET /api/auth/verify/:token. The backend flipsisVerifiedand returns a success message. - ✓Login is rejected (403) until the email is verified, then returns a single JWT token
- ✓A protected route returns 401 without the Bearer token, 200 with it