JWT (JSON Web Token) authentication is the standard authentication mechanism for MERN applications. Unlike session-based authentication โ where the server stores session data in a database and the client sends a session ID cookie โ JWT is stateless: all the information needed to authenticate a request is encoded directly in the token. The server does not need to look anything up; it just verifies the token’s signature. Understanding the complete JWT flow โ creation, storage, transmission, and verification โ is the foundation for the secure MERN Blog authentication system you will build in this chapter.
The JWT Authentication Flow
1. User submits email + password to POST /api/auth/login
โ
โผ
2. Express verifies credentials against MongoDB
(finds user, calls bcrypt.compare on password hash)
โ
โผ
3. Express signs a JWT using JWT_SECRET
Token payload: { id: user._id, iat: timestamp, exp: timestamp }
โ
โผ
4. Express returns the token in the response body
{ success: true, token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }
โ
โผ
5. React stores the token (localStorage or HttpOnly cookie)
โ
โผ
6. React attaches token to every subsequent API request
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
โ
โผ
7. Express protect middleware extracts + verifies the token
jwt.verify(token, JWT_SECRET) โ { id, iat, exp }
โ
โโโ Valid โ attaches decoded user to req.user โ handler proceeds
โโโ Invalid/expired โ 401 Unauthorized โ React clears token โ login page
jwt.verify() will reject it. However, the payload is only encoded, not encrypted โ anyone can decode it. Never put sensitive data (passwords, payment info) in a JWT payload.Anatomy of a JWT
A JWT looks like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJpZCI6IjY0YTFmMmIzYzhlNGQ1ZjZhN2I4YzlkMCIsImlhdCI6MTcxMDAwMDAwMCwiZXhwIjoxNzEwNjA0ODAwfQ
.r3Xxw0FhqwQqBptCBbKZ8EE-HzXi1vEBqVP2RrOnl9g
Decoded (at jwt.io):
Header: { "alg": "HS256", "typ": "JWT" }
Payload: { "id": "64a1f2b3c8e4d5f6a7b8c9d0", "iat": 1710000000, "exp": 1710604800 }
Signature: HMACSHA256(base64(header) + "." + base64(payload), JWT_SECRET)
Important:
โ The payload is BASE64 ENCODED โ anyone can decode it
โ The signature is what makes it tamper-proof, not encryption
โ Do NOT store passwords, payment info, or other secrets in payload
โ Only store identifiers: user._id, role (if needed for quick checks)
JWT vs Session Authentication
| Session-Based | JWT | |
|---|---|---|
| State storage | Server (database/Redis) | Client (token) |
| Scalability | Needs shared session store for multiple servers | Any server can verify โ stateless |
| Revocation | Instant โ delete session from store | Cannot revoke โ must wait for expiry |
| Storage | HttpOnly cookie (automatic, secure) | localStorage or HttpOnly cookie |
| CSRF risk | Yes โ cookie sent automatically | Not with Bearer header; yes if stored in cookie |
| Best for MERN | When instant revocation is critical | Stateless APIs, microservices, standard MERN apps |
What Gets Stored in the JWT Payload
// server โ signing the token (inside login/register controller)
const payload = {
id: user._id, // MongoDB ObjectId as string โ used to fetch user from DB
role: user.role, // include if your middleware needs role without a DB query
// iat (issued at) and exp (expires at) are added automatically by jsonwebtoken
};
const token = jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
});
// In the protect middleware, after verifying:
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// decoded = { id: '64a1f2b3...', role: 'user', iat: 1710..., exp: 1710... }
// Attach the real user from DB to req.user
req.user = await User.findById(decoded.id).select('-password');
// Always fetch from DB โ the token's payload may be stale
// (e.g. user role changed after token was issued)
Common Mistakes
Mistake 1 โ Storing sensitive data in the JWT payload
โ Wrong โ passwords or secrets in payload:
const token = jwt.sign({ id: user._id, password: user.password }, secret);
// The payload is base64-encoded โ anyone with the token can decode it
// Password hash visible to anyone who has the token!
โ Correct โ only store non-sensitive identifiers:
const token = jwt.sign({ id: user._id }, secret, { expiresIn: '7d' }); // โ
Mistake 2 โ Using a weak or hardcoded JWT secret
โ Wrong โ short or predictable secret in source code:
const token = jwt.sign({ id }, 'secret'); // trivially guessable
// Or hardcoded in source โ committed to git โ everyone can forge tokens!
โ Correct โ long random secret from environment variable:
// Generate: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
// JWT_SECRET=b3a8f2... (128 hex chars) in .env
const token = jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: '7d' }); // โ
Mistake 3 โ Not setting token expiry
โ Wrong โ token never expires:
jwt.sign({ id: user._id }, process.env.JWT_SECRET);
// No expiresIn โ token is valid forever โ stolen tokens never expire
โ Correct โ always set expiry:
jwt.sign({ id: user._id }, process.env.JWT_SECRET, { expiresIn: '7d' }); // โ
Quick Reference
| Concept | Key Point |
|---|---|
| JWT structure | Header.Payload.Signature โ all Base64-encoded |
| Payload is | Encoded (readable), NOT encrypted โ no secrets |
| Signature purpose | Proves the token was created by the server with JWT_SECRET |
| Stateless | Server does not store tokens โ verifies signature each request |
| Revocation | Cannot invalidate before expiry โ use short expiry |
| Standard expiry | 15m access token + 7โ30d refresh token for production |