Episode 3 — NodeJS MongoDB Backend Architecture / 3.14 — Authentication and Authorization
3.14.d — JWT Authentication
In one sentence: A JSON Web Token (JWT) is a compact, self-contained, digitally signed string with three parts -- Header, Payload, Signature -- that lets a server verify identity without storing session state, using the
jsonwebtokenlibrary in Node.js.
Navigation: <- 3.14.c Session vs Token Authentication | 3.14.e -- Auth Middleware ->
1. What is JWT?
JWT (pronounced "jot") stands for JSON Web Token. It is an open standard (RFC 7519) for securely transmitting information between parties as a JSON object.
Key properties:
- Compact -- small enough to send in a URL, header, or cookie
- Self-contained -- carries all necessary user info (no database lookup)
- Signed -- the server can verify it was not tampered with
- Not encrypted by default -- anyone can read the payload; only the server can verify it
2. JWT structure: Header.Payload.Signature
A JWT is three base64url-encoded segments separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NWFiYzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwMTIwMDAwMCwiZXhwIjoxNzAxMjg2NDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
│ │ │
└── Header (base64url) └── Payload (base64url) └── Signature
Header
{
"alg": "HS256", // signing algorithm: HMAC + SHA-256
"typ": "JWT" // token type
}
Common algorithms:
| Algorithm | Type | Description |
|---|---|---|
| HS256 | Symmetric | Same secret to sign and verify (simple, common) |
| HS384 | Symmetric | Stronger HMAC variant |
| RS256 | Asymmetric | Private key signs; public key verifies (microservices) |
| ES256 | Asymmetric | Elliptic curve; smaller keys than RSA |
Payload (claims)
{
"userId": "65abc123", // custom claim
"role": "admin", // custom claim
"email": "admin@app.com", // custom claim
"iat": 1701200000, // issued at (auto-added)
"exp": 1701286400 // expires at (24h later)
}
Registered claims (standard, optional):
| Claim | Name | Purpose |
|---|---|---|
sub | Subject | User identifier |
iat | Issued At | When the token was created (Unix timestamp) |
exp | Expiration | When the token expires (Unix timestamp) |
nbf | Not Before | Token not valid before this time |
iss | Issuer | Who issued the token |
aud | Audience | Intended recipient |
jti | JWT ID | Unique token identifier (for blacklisting) |
Warning: The payload is only base64-encoded, not encrypted. Anyone can decode it:
// Anyone can read the payload!
const payload = JSON.parse(atob('eyJ1c2VySWQiOiI2NWFiYzEyMyJ9'));
// { userId: "65abc123" }
Never put sensitive data (passwords, credit card numbers) in the payload.
Signature
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
The signature ensures:
- The token was created by someone who knows the secret
- The header and payload were not modified after signing
3. Installing jsonwebtoken
npm install jsonwebtoken
4. Creating (signing) a token
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET; // e.g., "k8$Lm9#pQ2xZ7..."
// Basic token creation
const token = jwt.sign(
{ userId: '65abc123', role: 'admin' }, // payload
SECRET, // secret key
{ expiresIn: '24h' } // options
);
console.log(token);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOi...
expiresIn format options:
| Format | Meaning |
|---|---|
'30s' | 30 seconds |
'15m' | 15 minutes |
'2h' | 2 hours |
'7d' | 7 days |
'1y' | 1 year |
3600 | 3600 seconds (number = seconds) |
5. Verifying (decoding) a token
try {
const decoded = jwt.verify(token, SECRET);
console.log(decoded);
// {
// userId: '65abc123',
// role: 'admin',
// iat: 1701200000, // issued at
// exp: 1701286400 // expires at
// }
} catch (err) {
if (err.name === 'TokenExpiredError') {
console.log('Token has expired');
} else if (err.name === 'JsonWebTokenError') {
console.log('Token is invalid (tampered or malformed)');
} else if (err.name === 'NotBeforeError') {
console.log('Token not yet active');
}
}
jwt.verify vs jwt.decode:
| Method | Verifies signature? | Checks expiry? | Use case |
|---|---|---|---|
jwt.verify(token, secret) | YES | YES | Always use this for authentication |
jwt.decode(token) | NO | NO | Debugging only; never for auth decisions |
6. Token expiration and refresh tokens
The problem
Short-lived tokens are safer (less damage if stolen), but force frequent re-login. Long-lived tokens are convenient but risky.
The solution: access + refresh token pattern
┌─────────────────────────────────────────────────┐
│ Token Lifecycle │
├─────────────────────────────────────────────────┤
│ │
│ ACCESS TOKEN REFRESH TOKEN │
│ ───────────── ────────────── │
│ Short-lived: 15m Long-lived: 7d │
│ Sent with every Sent only to refresh │
│ API request endpoint │
│ In memory / cookie In httpOnly cookie │
│ Contains user data Contains only userId │
│ Stateless Stored in DB (revocable) │
│ │
└─────────────────────────────────────────────────┘
Refresh token flow
1. Login → server returns accessToken (15m) + refreshToken (7d)
2. Client uses accessToken for API calls
3. accessToken expires → 401 response
4. Client sends refreshToken to /api/refresh
5. Server verifies refreshToken, checks DB
6. Server issues NEW accessToken (+ optionally new refreshToken)
7. Client retries the original request
Implementation
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
// --- Generate token pair ---
function generateTokens(user) {
const accessToken = jwt.sign(
{ userId: user._id, role: user.role },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user._id },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
// --- Login route ---
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !(await user.comparePassword(password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const { accessToken, refreshToken } = generateTokens(user);
// Store refresh token in database (for revocation)
user.refreshToken = refreshToken;
await user.save();
// Send refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/refresh', // only sent to refresh endpoint
});
// Send access token in response body
res.json({ accessToken, user });
});
// --- Refresh route ---
router.post('/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
// 1. Verify the refresh token
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
// 2. Check if token exists in database (not revoked)
const user = await User.findOne({
_id: decoded.userId,
refreshToken: refreshToken,
});
if (!user) {
return res.status(403).json({ error: 'Token revoked or user not found' });
}
// 3. Generate new tokens
const tokens = generateTokens(user);
// 4. Rotate refresh token (invalidate old one)
user.refreshToken = tokens.refreshToken;
await user.save();
// 5. Set new refresh token cookie
res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/refresh',
});
res.json({ accessToken: tokens.accessToken });
} catch (err) {
res.status(403).json({ error: 'Invalid refresh token' });
}
});
// --- Logout route ---
router.post('/logout', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
// Remove refresh token from database
await User.findOneAndUpdate(
{ refreshToken },
{ $unset: { refreshToken: 1 } }
);
}
res.clearCookie('refreshToken', { path: '/api/refresh' });
res.json({ message: 'Logged out' });
});
7. Where to store tokens
| Storage | XSS Safe? | CSRF Safe? | Recommendation |
|---|---|---|---|
| httpOnly cookie | YES (JS cannot read) | Needs sameSite | Preferred for web apps |
| localStorage | NO (JS can read = XSS risk) | YES (not auto-sent) | Avoid for sensitive tokens |
| sessionStorage | NO (same XSS risk) | YES | Slightly better (cleared on tab close) |
| In-memory (JS variable) | Moderate | YES | Good for access tokens (lost on refresh) |
Best practice for web apps:
Access token → in-memory JavaScript variable (or short-lived httpOnly cookie)
Refresh token → httpOnly, secure, sameSite cookie
8. JWT secret management
# Generate a strong random secret
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Rules:
- Never hardcode the secret in source code
- Use environment variables (
process.env.JWT_SECRET) - Make it long and random (64+ characters)
- Different secrets for access and refresh tokens
- Rotate secrets periodically (with a grace period for existing tokens)
.env file (never commit):
JWT_ACCESS_SECRET=a1b2c3d4e5f6...long_random_string_here
JWT_REFRESH_SECRET=x9y8z7w6v5u4...different_long_random_string
9. Complete auth flow: register, login, protected route
// --- models/User.js ---
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true, lowercase: true },
password: { type: String, required: true, select: false }, // excluded by default
role: { type: String, enum: ['user', 'admin'], default: 'user' },
refreshToken: { type: String, select: false },
}, { timestamps: true });
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
userSchema.methods.comparePassword = async function (candidate) {
return bcrypt.compare(candidate, this.password);
};
module.exports = mongoose.model('User', userSchema);
// --- middleware/auth.js ---
const jwt = require('jsonwebtoken');
function authenticate(req, res, next) {
// Check Authorization header first, then cookie
let token;
if (req.headers.authorization?.startsWith('Bearer ')) {
token = req.headers.authorization.split(' ')[1];
} else if (req.cookies?.accessToken) {
token = req.cookies.accessToken;
}
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
req.user = decoded; // { userId, role, iat, exp }
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
module.exports = { authenticate };
// --- routes/auth.js ---
const express = require('express');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
// Register
router.post('/register', async (req, res) => {
try {
const { name, email, password } = req.body;
if (await User.findOne({ email })) {
return res.status(409).json({ error: 'Email already registered' });
}
const user = await User.create({ name, email, password });
const accessToken = jwt.sign(
{ userId: user._id, role: user.role },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
);
res.status(201).json({ accessToken, user });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email }).select('+password');
if (!user || !(await user.comparePassword(password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const accessToken = jwt.sign(
{ userId: user._id, role: user.role },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken, user });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Protected route
router.get('/profile', authenticate, async (req, res) => {
const user = await User.findById(req.user.userId);
res.json({ user });
});
module.exports = router;
10. JWT vs opaque tokens
| JWT (self-contained) | Opaque Token | |
|---|---|---|
| Format | Base64-encoded JSON + signature | Random string (a8f3k2...) |
| Contains user data? | Yes (in payload) | No (just an identifier) |
| Server lookup needed? | No (verify signature only) | Yes (look up in database/cache) |
| Size | Large (500+ bytes) | Small (~32 bytes) |
| Revocation | Hard (needs blacklist) | Easy (delete from store) |
| Debugging | Easy (decode and read) | Hard (opaque string) |
| Best for | Stateless APIs, microservices | When revocation is critical |
11. Key takeaways
- JWT has three parts: Header (algorithm) + Payload (claims) + Signature (verification).
- Use
jwt.sign()to create andjwt.verify()to validate -- never usejwt.decode()for auth. - The access + refresh token pattern balances security (short-lived) with UX (no constant re-login).
- Store refresh tokens in httpOnly cookies; store access tokens in memory or short-lived cookies.
- JWT secrets must be strong, random, and from environment variables -- never hardcoded.
- The payload is readable by anyone -- never put passwords or secrets in it.
Explain-It Challenge
Explain without notes:
- If a JWT payload is just base64-encoded (not encrypted), how does the server know it was not tampered with?
- Why should access tokens be short-lived (15 minutes) while refresh tokens can be longer (7 days)?
- An attacker steals a JWT from localStorage via an XSS attack. What can they do, and how would storing the token in an httpOnly cookie have prevented this?
Navigation: <- 3.14.c Session vs Token Authentication | 3.14.e -- Auth Middleware ->