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 to receive a JWT Bearer token, and lock the product routes behind that token.

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 crypto-token link
2 · Verify
user clicks link
match verificationToken
isVerified = true
3 · Login + Protect
bcrypt.compare
sign one JWT → Bearer token
guard on every route

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:

TypeORM (Postgres)TypeORM

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

Mongoose (MongoDB)Mongoose

// import User from '../models/User.js'
User.findOne({ email })
User.create({ … })
User.findByIdAndUpdate(id, { … })
02 — BUILDING BLOCKS

Validation Rules & the Mailer

Two small files every handler reuses: express-validator rules that reject bad input, and a Nodemailer transport that sends email.

server/src/routes/auth.js — validation rules
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 instead
server/src/services/emailService.js
import 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>`,
  })
}
How the email is sent

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.

03 — REGISTER

Registration + Verification Email

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

server/src/controllers/authController.js
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)
  }
}
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-email/: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, match the stored verificationToken (before it expires), flip isVerified to true, and let them log in.

server/src/controllers/authController.js
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)
  }
}
05 — LOGIN

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.

One token — Bearer only

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.

server/src/controllers/authController.js
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)
  }
}
06 — PROTECT

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.

server/src/middleware/authMiddleware.js
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)
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 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

web/app/register/page.tsx ("use client")
"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

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

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 flips isVerified and 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