How JWT Authentication Works in a MERN Application

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
Note: A JWT has three Base64-encoded parts separated by dots: Header (algorithm and token type), Payload (claims โ€” the data stored in the token), and Signature (HMAC of header + payload using the secret key). The signature is what makes the token tamper-proof โ€” any modification to the header or payload invalidates the signature, and 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.
Tip: Use short token expiry times in production โ€” 15 minutes for access tokens is common. Pair them with longer-lived refresh tokens (7โ€“30 days) to maintain sessions without requiring frequent logins. The access token expiry is the window of vulnerability if a token is stolen. A 15-minute access token limits the damage to 15 minutes. For the MERN Blog in development, a 7-day token is fine for convenience, but understand the security trade-off.
Warning: JWTs are stateless โ€” once issued, a token cannot be invalidated before it expires. If a user logs out or changes their password, the old token remains valid until it expires. For the MERN Blog this is acceptable, but for high-security applications (banking, healthcare) you need a token blacklist in Redis or a short expiry + refresh token pattern. Always use the shortest expiry time that provides acceptable UX for your use case.

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

🧠 Test Yourself

A colleague says “Our JWT is secure because the token is encrypted โ€” no one can read the user’s email that we stored in the payload.” Is this correct?