Asia/Kolkata
BlogSeptember 29, 2026

MERN JWT Authentication Tutorial: Secure Login from Scratch

Mahenoor Salat
Most MERN auth tutorials stop at "it logs in on localhost." Production auth has to survive real users: hashed passwords, expiring sessions, protected routes, and role checks enforced on the server. Here is the exact pattern I use — the same one running in my open-source mern-auth-app.
[React form] → POST /api/auth/login → [Express: find user → bcrypt.compare]
    → sign JWT (15-min expiry) → [React AuthContext stores session]
    → every protected request carries the token → [auth middleware verifies]
Three rules that prevent 90% of auth bugs:
  1. Never store plain-text passwords. Hash with bcrypt (cost factor 10–12).
  2. Never trust the client. Every protected route re-verifies the token server-side.
  3. Expire sessions. Short-lived access tokens beat "remember me forever."
Typescript
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { User } from "./models/User";

// POST /api/auth/register
export async function register(req, res) {
  const { name, email, password } = req.body;
  if (!name || !email || !password) {
    return res.status(400).json({ error: "All fields are required." });
  }
  const exists = await User.findOne({ email });
  if (exists) return res.status(409).json({ error: "Email already registered." });

  const passwordHash = await bcrypt.hash(password, 12);
  const user = await User.create({ name, email, passwordHash });
  return res.status(201).json({ id: user._id, name: user.name, email: user.email });
}

// POST /api/auth/login
export async function login(req, res) {
  const { email, password } = req.body;
  const user = await User.findOne({ email });
  const valid = user && (await bcrypt.compare(password, user.passwordHash));
  if (!valid) return res.status(401).json({ error: "Invalid credentials." });

  const token = jwt.sign(
    { sub: user._id, role: user.role },
    process.env.JWT_SECRET!,
    { expiresIn: "15m" }
  );
  return res.json({ token, user: { id: user._id, name: user.name, role: user.role } });
}
Note the login response is deliberately vague ("Invalid credentials") — never reveal whether the email exists.
Typescript
import jwt from "jsonwebtoken";

export function requireAuth(req, res, next) {
  const header = req.headers.authorization || "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: "Missing token." });

  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET!);
    return next();
  } catch {
    return res.status(401).json({ error: "Invalid or expired token." });
  }
}

// Role check composes on top — e.g. admin-only routes:
export function requireRole(role: string) {
  return (req, res, next) =>
    req.user?.role === role
      ? next()
      : res.status(403).json({ error: "Forbidden." });
}
Usage: app.get("/api/admin/users", requireAuth, requireRole("admin"), handler). Roles live in the signed token, so they can't be forged client-side.
Tsx
const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [session, setSession] = useState(() => loadSession());

  async function login(email, password) {
    const res = await fetch("/api/auth/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password }),
    });
    if (!res.ok) throw new Error("Login failed.");
    const data = await res.json();
    saveSession(data); // token + user, never the password
    setSession(data);
  }

  function logout() { clearSession(); setSession(null); }

  return (
    <AuthContext.Provider value={{ session, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export function ProtectedRoute({ children }) {
  const { session } = useAuth();
  return session ? children : <Navigate to="/login" />;
}
| | JWT (stateless) | Sessions (stateful) | |---|---|---| | Server storage | None — verified by signature | Session store (DB/Redis) | | Scaling | Any server verifies, no shared state | Needs shared store across servers | | Logout/revoke | Waits for expiry (use short lifetimes) | Instant (delete the session) | | Best for | Standard MERN SPAs, mobile APIs | Banking/healthcare, instant-revoke needs | For most MERN apps JWT wins on simplicity. If you need instant revocation, pair short access tokens (15 min) with rotating refresh tokens below.
Typescript
// POST /api/auth/refresh — body: { refreshToken }
export async function refresh(req, res) {
  const { refreshToken } = req.body;
  const stored = await RefreshToken.findOne({ token: refreshToken });
  // Rotate: single-use refresh tokens, delete after use
  if (!stored) return res.status(401).json({ error: "Invalid refresh token." });
  await RefreshToken.deleteOne({ token: refreshToken });

  const accessToken = jwt.sign(
    { sub: stored.userId, role: stored.role },
    process.env.JWT_SECRET!,
    { expiresIn: "15m" }
  );
  const next = await RefreshToken.create({
    userId: stored.userId, role: stored.role, token: randomToken(),
  });
  return res.json({ token: accessToken, refreshToken: next.token });
}
Client rule: on any 401, call /refresh once, retry the request, and only redirect to login if refresh also fails.
  1. Secrets in the JWT payload. Payloads are Base64, not encrypted — anyone can decode them. Store only sub (user id) and role.
  2. No expiry. Always set expiresIn. A stolen never-expiring token is a permanent backdoor.
  3. Trusting localStorage blindly. XSS can read it. HttpOnly cookies are safer against XSS (pair with CSRF protection); Bearer headers avoid CSRF but not XSS. Pick based on your threat model — and never skip server-side verification either way.
  • JWT_SECRET is long, random, and only in server env — never shipped to the client.
  • Rate-limit /login and /register (brute-force protection).
  • HTTPS only; set Secure, HttpOnly, SameSite if you move tokens into cookies.
  • Add refresh tokens if 15-minute sessions annoy users — rotate them on every use.
  • Log failed logins; alert on spikes.
The complete working app — Backend, frontend, and README setup — is open source: mern-auth-app on GitHub. It is also a portfolio case study with screenshots: MERN Auth App. Need authentication (or a full SaaS MVP) built for your product? Hire me — send the brief, get a fixed quote in 48 hours.
Share this post: