What you'll learn
By the end of this tutorial you'll know how to add JWT-based authentication to a MERN app from scratch. You'll understand how passwords are stored safely with bcrypt, how JWTs are created and verified, how protected routes work in Express using middleware, and how React stores and sends the token. You'll have working, readable auth code you can adapt to any project.
Authentication is one of the first features that trips up MERN developers because it crosses all four layers simultaneously. This tutorial connects the dots rather than showing each piece in isolation.
Who this is for
- MERN developers who've built public routes and now need to add login and protected pages
- Developers who've seen JWT tutorials but aren't sure how all the pieces connect specifically in Express + React
- Anyone who's copied auth code before without fully understanding it well enough to debug it
You can skip this if you're already using a managed auth service like Clerk or Auth0. Managed auth is often the right call for production apps — better security, less code to maintain, faster to ship. This tutorial is for when you want to understand the fundamentals or build it yourself for a learning project.
What is JWT authentication?
JWT (JSON Web Token) authentication is a stateless way to verify that a user is who they claim to be. The server creates a signed token when the user logs in. The client stores that token and sends it with every subsequent protected request. The server verifies the signature — no database lookup required to confirm the token is valid.
Plain English: when you log in, the server hands you a signed pass. Every time you knock on a protected door, you show the pass. The server checks the signature — if it's valid and not expired, you're in.
Simple idea: instead of the server keeping a list of who's logged in (session-based auth), the token itself contains the proof. The server only needs the secret it used to sign the token, nothing else.
Prerequisites
- A working MERN architecture (see MERN stack architecture if you need a starting point)
- An existing Mongoose User model, or the ability to create one
- Basic understanding of async/await and try/catch in JavaScript
Setup from zero
Step 1 — Install the auth packages
cd server
npm install jsonwebtoken bcryptjs
jsonwebtoken creates and verifies JWTs. bcryptjs hashes passwords — it's a pure JavaScript implementation that doesn't require native compilation, so it builds reliably on Windows, Mac, and Linux without configuration.
Add your secrets to .env:
JWT_SECRET=replace_this_with_a_long_random_string_at_least_32_chars
JWT_EXPIRES_IN=7d
Generate a real secret: openssl rand -hex 32 in your terminal. Store the output in JWT_SECRET. Never use a short or guessable string here.
Step 2 — Create the User model
// server/models/User.js
const mongoose = require("mongoose");
const userSchema = new mongoose.Schema(
{
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
password: { type: String, required: true },
},
{ timestamps: true }
);
module.exports = mongoose.model("User", userSchema);
> Little tip: Always store email as lowercase. Use lowercase: true in the schema so User@Example.com and user@example.com resolve to the same account. This is one of those bugs that doesn't show up in development — where you control the test data — and bites users the first time they try to log in with a capital letter.
Step 3 — Build register and login routes
// server/routes/auth.js
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const User = require("../models/User");
const router = express.Router();
// POST /api/auth/register
router.post("/register", async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: "Email and password are required" });
}
const exists = await User.findOne({ email });
if (exists) return res.status(400).json({ error: "Email already registered" });
const hash = await bcrypt.hash(password, 12);
const user = await User.create({ email, password: hash });
const token = jwt.sign(
{ id: user._id },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN }
);
res.status(201).json({ token });
} catch (err) {
res.status(500).json({ error: "Registration failed" });
}
});
// POST /api/auth/login
router.post("/login", async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) return res.status(401).json({ error: "Invalid credentials" });
const valid = await bcrypt.compare(password, user.password);
if (!valid) return res.status(401).json({ error: "Invalid credentials" });
const token = jwt.sign(
{ id: user._id },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN }
);
res.json({ token });
} catch (err) {
res.status(500).json({ error: "Login failed" });
}
});
module.exports = router;
Register the routes in server/index.js:
const authRoutes = require("./routes/auth");
app.use("/api/auth", authRoutes);
The mental model
Think of JWT auth as three completely separate problems that happen to work together: proving identity (login), issuing a pass (JWT creation), and checking the pass (middleware on protected routes).
Most auth bugs come from conflating these three. Registration and login are one-time handshakes. The JWT is the evidence that the handshake happened — it's just a signed string that carries the user's ID and an expiry time. The middleware is the bouncer that reads that evidence on every subsequent request — it doesn't care how the token was created, only whether the signature is valid and the expiry hasn't passed.
The stateless part matters practically: Express doesn't store any session data between requests. Every request is completely independent. The JWT carries everything the server needs to know who's making the request: specifically, the user's _id embedded in the payload.
Key terms
bcrypt — a password hashing algorithm designed to be intentionally slow, making brute-force attacks expensive. The second argument to bcrypt.hash() (12 in the example) is the cost factor: higher is slower and more secure.
JWT (JSON Web Token) — a signed, base64-encoded string with three parts separated by dots: header, payload, signature. The signature is what makes it tamper-evident. Example format: eyJhbGc...header.eyJpZCI...payload.SflKxw...signature.
Payload — the data encoded inside the JWT. Usually includes id (user ID) plus iat (issued at) and exp (expiry) timestamps added automatically by jsonwebtoken.
Middleware — a function that runs before your route controller. Auth middleware extracts the token from the request header, verifies it, and attaches the user ID to req. If the token is missing or invalid, it sends 401 immediately.
Protected route — a route that applies the auth middleware first. The controller only runs if the middleware passes. If not, the client gets 401 without the controller ever executing.
Step-by-step: protecting routes with middleware
Write the auth middleware
// server/middleware/auth.js
const jwt = require("jsonwebtoken");
module.exports = function protect(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
return res.status(401).json({ error: "No token provided" });
}
const token = header.split(" ")[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.userId = decoded.id;
next();
} catch {
res.status(401).json({ error: "Invalid or expired token" });
}
};
Apply it to a protected route
// server/routes/posts.js
const protect = require("../middleware/auth");
router.get("/my-posts", protect, async (req, res) => {
const posts = await Post.find({ authorId: req.userId }).sort({ createdAt: -1 });
res.json(posts);
});
The protect middleware runs before the controller. If the token is valid, req.userId is populated and next() is called. If not, 401 is returned and the controller never runs.
React: store and send the token
// client/src/api/auth.ts
export async function login(email: string, password: string): Promise<void> {
const res = await fetch("http://localhost:5000/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
localStorage.setItem("token", data.token);
}
export function authHeaders(): Record<string, string> {
const token = localStorage.getItem("token");
return token ? { Authorization: `Bearer ${token}` } : {};
}
export function logout(): void {
localStorage.removeItem("token");
}
// Using the helper in any protected fetch
const res = await fetch("http://localhost:5000/api/posts/my-posts", {
headers: authHeaders(),
});
> Little tip: localStorage is fine for development and low-sensitivity apps, but httpOnly cookies are more secure for production because browser JavaScript can't read them — XSS attacks that steal tokens from localStorage become impossible. This is a one-time migration when you're ready to harden the app.
Patterns
Generic error messages for auth failures — return "Invalid credentials" for both wrong email and wrong password. Never say "email not found" — that tells an attacker which emails have accounts (user enumeration). Always give the same unhelpful error for both failure modes.
Single protect middleware, applied per-route — one middleware function, applied explicitly to every route that needs it. Avoid global middleware that protects all routes by default; it's harder to reason about which routes are public.
Attach minimal data to the JWT payload — put only the user ID in the token, not roles, email, or other fields that might change. Fetch fresh user data from the database in routes that need it using req.userId.
Common mistakes
Storing the plain-text password instead of the hash — always call bcrypt.hash(password, 12) before User.create(). If you accidentally store plain text, issue a password reset for all affected users immediately — plain-text passwords in a database are an unrecoverable security incident.
Using a weak or hardcoded JWT secret — "secret" is trivially brute-forced offline. Use a random 32+ character hex string. Rotate the secret if it's ever exposed; all existing tokens become invalid immediately when you do.
Not returning after early error responses — missing return before a res.status(...).json(...) call in auth routes means Express tries to send a second response after the first, throwing "Cannot set headers after they are sent." Add return before every early response.
Troubleshooting
401 on every protected request — usually a missing or malformed Authorization header. Add console.log(req.headers.authorization) in the middleware temporarily to see exactly what's arriving. Common cause: React code that forgot to include authHeaders() in the fetch options.
bcrypt is very slow in development — a cost factor above 14 can make hashing take several seconds on development hardware. Use 10–12 for development and 12–14 for production on standard servers. The right tradeoff depends on your hardware; 12 is safe for most use cases.
JWT payload shows stale data — JWTs are issued once and don't update automatically. If a user's email or role changes after token issuance, existing tokens still carry the old values until they expire. For data that must be fresh on every request (roles, subscription status), store only the user ID in the token and fetch current data from MongoDB in the protected route.
Checklist
- [ ] Passwords hashed with bcrypt before any document is saved
- [ ] JWT secret is a long random string in
.env, never written in code - [ ] Login returns "Invalid credentials" for both wrong email and wrong password
- [ ] Auth middleware verifies the token and populates
req.userId - [ ] Every protected route imports and applies
protectbefore its controller - [ ] React stores the token and attaches it in the
Authorization: Bearerheader - [ ] Token has an expiry time set with
expiresIn - [ ]
returnappears before every early error response
Practice task
Add authentication to the notes app from MERN stack architecture. Users register and log in. Each Note document gets an authorId field. The POST /api/notes and GET /api/notes routes become protected — apply the protect middleware to both. When a note is created, set authorId from req.userId. When notes are listed, return only notes belonging to the requesting user. Test the full flow: register, log in, create a note, verify the list only contains your note.
FAQ
How long should the JWT expiry be?
For a development or low-risk app, 7 days is reasonable and convenient. For apps with sensitive data, use 15–60 minute access tokens combined with longer-lived refresh tokens. The shorter the expiry, the less time a stolen token is useful to an attacker.
Can I invalidate a JWT before it expires?
Stateless JWTs cannot be invalidated before expiry without storing state — which partially defeats the stateless advantage. The common practical solutions are: keep expiry times short so stolen tokens are short-lived, and delete the token from localStorage on logout so the client stops sending it. For high-security apps, maintain a server-side token blocklist in Redis.
What's the difference between authentication and authorization?
Authentication is "who are you?" — the JWT answers this by proving the user's identity. Authorization is "what are you allowed to do?" — checked after authentication, usually by comparing the user's role or ownership of a resource. The auth middleware handles authentication; the controller handles authorization.
What to learn next
Once basic auth is working: role-based access control (admin vs. regular user routes), email verification flows (confirming email on registration), and password reset with time-limited tokens sent by email.
Related on Baseline
- MERN stack architecture — the foundational layer that auth builds on top of
- MERN API design — structuring your auth routes within a larger Express architecture
- MERN deployment checklist — securing auth secrets correctly for production
Takeaways
MERN JWT auth has three clear responsibilities: bcrypt handles safe password storage, JWT handles the signed token that proves identity, and Express middleware handles route protection. Each piece has one job. Keep them in separate files and the whole system stays readable and debuggable.
If you remember only one thing: never store a plain-text password. Hash with bcrypt before the User document hits the database — every registration, no exceptions. Everything else in auth can be refactored or hardened iteratively. A plaintext password in a database breach is an incident you cannot undo.