SkillShare Logo
STEP 4 OF 7 · SECURITY

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.

01 — THE FLOW

How Auth Works Here

No third-party auth service — just three trustworthy libraries. This makes the flow explicit and identical across every stack.

1 · Register
bcrypt hashes password
save user (isVerified=false)
email a signed link
2 · Verify
user clicks link
jwt.verify(token)
isVerified = true
3 · Login + Protect
bcrypt.compare
JWT → httpOnly cookie
guard on every route

Database calls — translate as you go

Backend snippets below use TypeORM repository calls. If you chose MongoDB, swap them for the Mongoose equivalent:

TypeORM (Postgres)TypeORM

const repo = AppDataSource.getRepository(User);
repo.findOneBy({ email })
repo.save(repo.create({ … }))
repo.update(id, { … })

Mongoose (MongoDB)Mongoose

// import { UserModel }
UserModel.findOne({ email })
UserModel.create({ … })
UserModel.findByIdAndUpdate(id, { … })
02 — BUILDING BLOCKS

Validation Schemas & the Mailer

Two small files every handler reuses: Zod schemas that reject bad input, and a Nodemailer transport that sends email.

server/src/schemas.ts
import { z } from "zod";

export const RegisterSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});
export const LoginSchema = RegisterSchema;
server/src/mailer.ts
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 });
}
NestJS tip

Wrap these as injectable providers (MailService) so you can constructor(private mail: MailService) them into controllers. The send logic is identical.

03 — REGISTER

Registration + Verification Email

Hash the password, save an unverified user, then email a signed link that proves they own the address.

server/src/auth.routes.ts
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." });
});
Where does the link point?

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.

04 — VERIFY

Verify the Email Link

When the user clicks the link, decode the token, flip isVerified to true, and bounce them to the login page.

server/src/auth.routes.ts
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.");
  }
});
05 — LOGIN

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

Enable cookies + CORS first

In index.ts: app.use(cookieParser()) and app.use(cors({ origin: process.env.CLIENT_URL, credentials: true })).

server/src/auth.routes.ts
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 });
});
06 — PROTECT

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.

server/src/auth.middleware.ts
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);
Next.js Middleware caveat

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.

07 — FRONTEND

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.

web/app/login/page.tsx ("use client")
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
}
Store the session

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 isVerified and redirects to login
  • Login is rejected until verified, then sets the cookie
  • A protected route returns 401 without the cookie, 200 with it