Authentication & Email Verification
The secure core: register with a hashed password, verify the email before access, log in with a JWT cookie, and lock the product routes behind that login.
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 — translate as you go
Backend snippets below use TypeORM repository calls. If you chose MongoDB, swap them for the Mongoose equivalent:
Validation Schemas & the Mailer
Two small files every handler reuses: Zod schemas that reject bad input, and a Nodemailer transport that sends email.
import { z } from "zod";
export const RegisterSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export const LoginSchema = RegisterSchema;import nodemailer from "nodemailer";
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
export function sendMail(to: string, subject: string, html: string) {
return transport.sendMail({ from: process.env.MAIL_FROM, to, subject, html });
}Wrap these as injectable providers (MailService) so you can constructor(private mail: MailService) them into controllers. The send logic is identical.
Registration + Verification Email
Hash the password, save an unverified user, then email a signed link that proves they own the address.
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { RegisterSchema } from "./schemas";
import { sendMail } from "./mailer";
router.post("/register", async (req, res) => {
const parsed = RegisterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json(parsed.error.flatten());
const { email, password } = parsed.data;
if (await repo.findOneBy({ email }))
return res.status(409).json({ error: "Email already registered" });
const passwordHash = await bcrypt.hash(password, 10);
const user = await repo.save(repo.create({ email, passwordHash }));
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET!, { expiresIn: "1d" });
const link = `http://localhost:4000/api/auth/verify?token=${token}`;
await sendMail(email, "Verify your email",
`<p>Welcome! <a href="${link}">Click here to verify</a>.</p>`);
res.status(201).json({ message: "Check your email to verify your account." });
});Here it hits the backend verify route directly. You could instead point it at a frontend page (CLIENT_URL/verify?token=…) that calls the API — useful if you want a branded “verified!” screen.
Verify the Email Link
When the user clicks the link, decode the token, flip isVerified to true, and bounce them to the login page.
router.get("/verify", async (req, res) => {
try {
const { id } = jwt.verify(String(req.query.token), process.env.JWT_SECRET!) as { id: string };
await repo.update(id, { isVerified: true });
res.redirect(`${process.env.CLIENT_URL}/login?verified=1`);
} catch {
res.status(400).send("Invalid or expired verification link.");
}
});Login + Session Cookie
Check the password, refuse unverified accounts, then store a signed JWT in an httpOnly cookie so JavaScript can't read it (XSS-safe).
In index.ts: app.use(cookieParser()) and app.use(cors({ origin: process.env.CLIENT_URL, credentials: true })).
router.post("/login", async (req, res) => {
const { email, password } = LoginSchema.parse(req.body);
const user = await repo.findOneBy({ email });
if (!user || !(await bcrypt.compare(password, user.passwordHash)))
return res.status(401).json({ error: "Invalid credentials" });
if (!user.isVerified)
return res.status(403).json({ error: "Please verify your email first" });
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET!, { expiresIn: "7d" });
res
.cookie("token", token, { httpOnly: true, sameSite: "lax", secure: false })
.json({ id: user.id, email: user.email });
});Protect the Routes
A small gate that reads the cookie, verifies the JWT, and attaches the user id. Put it in front of every product route.
import jwt from "jsonwebtoken";
import { Request, Response, NextFunction } from "express";
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const token = req.cookies?.token;
if (!token) return res.status(401).json({ error: "Not logged in" });
try {
const { id } = jwt.verify(token, process.env.JWT_SECRET!) as { id: string };
(req as any).userId = id;
next();
} catch {
res.status(401).json({ error: "Invalid session" });
}
}
// usage: app.use("/api/products", requireAuth, 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 a form that POSTs to the API. The one crucial detail: credentials: "include" so the auth cookie is sent and stored.
async function login(email: string, password: string) {
const res = await fetch("http://localhost:4000/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include", // send/receive the cookie
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error((await res.json()).error);
return res.json(); // { id, email } → save in Zustand
}Save the returned { id, email } in a Zustand store. Add a /api/auth/me route (returns the user if the cookie is valid) so a refresh can restore the session, and a /api/auth/logout that clears the cookie.
- ✓Register creates an unverified user and an email lands in Mailtrap
- ✓Clicking the link flips
isVerifiedand redirects to login - ✓Login is rejected until verified, then sets the cookie
- ✓A protected route returns 401 without the cookie, 200 with it