π« JWT Authentication Implementation
JSON Web Tokens are the workhorse of stateless API authentication. In this lesson you'll pull a JWT apart to see exactly how its signature guarantees trust, then build a complete, production-shaped auth system in Node.js β registration, login, protected routes, refresh tokens, and role-based access β while sidestepping the pitfalls that sink real deployments.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain a JWT's three parts β header, payload, signature β and how the signature prevents tampering
- Build register and login endpoints in Express that issue signed JWTs
- Write middleware to protect routes and enforce role-based authorization
- Add a refresh-token mechanism and understand when token blacklisting is justified
- Choose between HS256 and RS256 and avoid the classic JWT security mistakes
Estimated Time: 50β70 minutes β’ Difficulty: Intermediate
Hands-on: Decode a real JWT by hand, then implement a token-verifying middleware.
In This Lesson
Why JWTs?
A JSON Web Token is a compact, URL-safe, digitally signed container of claims about a user. Its defining feature is that it is self-contained: the token itself carries the user's identity, so an API server can verify a request without looking anything up in a database or session store.
π‘ A useful analogy: A JWT is like a passport. It's issued by a trusted authority, carries your identity details, and has anti-forgery features (the signature) so any border officer can trust it at a glance β without phoning the issuing country. But like a passport, anyone who holds it can present it, so it must be kept safe and given a sensible expiry.
That statelessness is what makes JWTs a natural fit for APIs, single-page apps, mobile clients, and microservices β but it's also the source of their sharpest trade-off, which we'll return to in the refresh-token section.
Anatomy of a JWT
A JWT is three base64url-encoded segments joined by dots:
header.payload.signature
Header
Declares the token type and the signing algorithm.
{
"alg": "HS256",
"typ": "JWT"
}
Payload
Carries the claims β statements about the user plus metadata. Registered claims have standard short names:
{
"sub": "1234567890", // subject β the user ID
"role": "admin", // a custom (private) claim
"iat": 1516239022, // issued at (Unix seconds)
"exp": 1516242622 // expiration (Unix seconds)
}
π Common registered claims
iss issuer β’ sub subject (user) β’ aud audience β’ exp expiration β’ iat issued at β’ nbf not before
Signature
The signature is what makes the token trustworthy. For HS256 it is computed as:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
If an attacker edits the payload β say, flipping "role": "user" to "role": "admin" β the signature no longer matches, and verification fails. That is the whole security model.
β οΈ Encoded is not encrypted
base64url is encoding, not encryption. Anyone holding a JWT can decode and read the payload β paste one into jwt.io and see for yourself. Never put secrets (passwords, card numbers, private data) in a JWT payload.
A real HS256 token looks like this (line-wrapped for readability):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Building JWT Auth in Node.js
Let's build a real system with Express and Mongoose. Start by installing the pieces:
npm install express jsonwebtoken bcrypt mongoose dotenv cors
Keep secrets and config out of your code, in a .env file:
MONGO_URI=mongodb://localhost:27017/jwt-auth-demo
JWT_SECRET=change_me_to_a_long_random_string
JWT_EXPIRE=15m
PORT=3000
The User model
The model hashes the password automatically before saving and exposes helper methods for comparison and token creation:
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const UserSchema = new mongoose.Schema({
username: { type: String, required: true, unique: true, trim: true, maxlength: 50 },
email: { type: String, required: true, unique: true,
match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email'] },
password: { type: String, required: true, minlength: 8, select: false }, // hidden by default
role: { type: String, enum: ['user', 'admin'], default: 'user' },
createdAt:{ type: Date, default: Date.now }
});
// Hash the password only when it changes
UserSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
const salt = await bcrypt.genSalt(12);
this.password = await bcrypt.hash(this.password, salt);
next();
});
UserSchema.methods.matchPassword = function (entered) {
return bcrypt.compare(entered, this.password);
};
UserSchema.methods.getSignedJwt = function () {
return jwt.sign(
{ id: this._id, role: this.role },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRE }
);
};
module.exports = mongoose.model('User', UserSchema);
β οΈ Guard the pre-save hook
Notice the early return in pre('save'). A subtle but common bug is calling next() without returning β the hook then continues and re-hashes an already-hashed password. Always short-circuit.
The auth controller
Registration creates the user and returns a token; login verifies credentials and does the same. A shared helper keeps the response shape consistent:
// controllers/authController.js
const User = require('../models/User');
exports.register = async (req, res) => {
try {
const { username, email, password } = req.body;
const user = await User.create({ username, email, password });
sendToken(user, 201, res);
} catch (err) {
res.status(400).json({ success: false, error: err.message });
}
};
exports.login = async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password)
return res.status(400).json({ success: false, error: 'Provide email and password' });
// password has select:false, so explicitly request it
const user = await User.findOne({ email }).select('+password');
if (!user || !(await user.matchPassword(password)))
return res.status(401).json({ success: false, error: 'Invalid credentials' });
sendToken(user, 200, res);
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
};
exports.getMe = async (req, res) => {
const user = await User.findById(req.user.id);
res.status(200).json({ success: true, data: user });
};
function sendToken(user, statusCode, res) {
const token = user.getSignedJwt();
res.status(statusCode).json({ success: true, token });
}
π‘ Return the same error for both failures
Both "no such user" and "wrong password" return the identical 401 Invalid credentials. Distinguishing them would let an attacker enumerate which emails have accounts.
Protecting Routes & Roles
A single protect middleware verifies the token on every guarded request, and an authorize factory restricts routes to specific roles.
// middleware/authMiddleware.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
exports.protect = async (req, res, next) => {
let token;
const header = req.headers.authorization;
if (header && header.startsWith('Bearer')) token = header.split(' ')[1];
if (!token)
return res.status(401).json({ success: false, error: 'Not authorized' });
try {
// Always pin the allowed algorithm β see the pitfalls section
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
req.user = await User.findById(decoded.id);
if (!req.user) return res.status(401).json({ success: false, error: 'User no longer exists' });
next();
} catch (err) {
return res.status(401).json({ success: false, error: 'Not authorized' });
}
};
// Usage: authorize('admin') or authorize('admin', 'editor')
exports.authorize = (...roles) => (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({
success: false,
error: `Role '${req.user.role}' is not permitted here`
});
}
next();
};
Wiring it together in routes reads almost like plain English:
// routes/userRoutes.js
const express = require('express');
const { protect, authorize } = require('../middleware/authMiddleware');
const router = express.Router();
// Any logged-in user
router.get('/profile', protect, (req, res) => {
res.json({ success: true, user: req.user });
});
// Admins only
router.get('/', protect, authorize('admin'), (req, res) => {
res.json({ success: true, message: 'Full user list' });
});
module.exports = router;
β 401 vs. 403
Return 401 Unauthorized when the request lacks a valid token (we don't know who you are). Return 403 Forbidden when the token is valid but the role isn't allowed (we know who you are, and no). Getting these right makes clients β and your logs β far easier to reason about.
Refresh Tokens & Blacklisting
Here's the sharp trade-off promised earlier. Because a JWT is stateless, you can't revoke one before it expires β logging out doesn't un-sign a token already in the wild. Two strategies address this.
Strategy 1: short access tokens + refresh tokens (preferred)
Issue an access token that lives ~15 minutes and a refresh token that lives days, stored server-side so it can be revoked:
// A dedicated store lets you revoke refresh tokens on logout
// models/RefreshToken.js
const mongoose = require('mongoose');
module.exports = mongoose.model('RefreshToken', new mongoose.Schema({
tokenHash: { type: String, required: true, index: true }, // store HASHED, not raw
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
expiresAt: { type: Date, required: true },
createdAt: { type: Date, default: Date.now }
}));
// controllers/authController.js β exchange a refresh token for a new access token
exports.refresh = async (req, res) => {
const raw = req.body.refreshToken;
if (!raw) return res.status(400).json({ success: false, error: 'No refresh token' });
const stored = await RefreshToken.findOne({ tokenHash: sha256(raw) });
if (!stored || stored.expiresAt < new Date())
return res.status(401).json({ success: false, error: 'Invalid refresh token' });
const user = await User.findById(stored.userId);
await stored.deleteOne(); // rotate: single-use
const newRefresh = await issueRefreshToken(user); // store its hash
res.json({ success: true, accessToken: user.getSignedJwt(), refreshToken: newRefresh });
};
Strategy 2: token blacklisting
If you must invalidate an access token immediately (say, a compromised account), keep a blacklist and check it on every request. A TTL index auto-purges entries once they'd have expired anyway:
// models/BlacklistedToken.js
const mongoose = require('mongoose');
module.exports = mongoose.model('BlacklistedToken', new mongoose.Schema({
token: { type: String, required: true, unique: true },
createdAt: { type: Date, default: Date.now, expires: 86400 } // TTL: auto-delete after 24h
}));
β οΈ Blacklisting sacrifices statelessness
Checking a database on every request is exactly the state you adopted JWTs to avoid. Reach for it only when you truly need instant revocation. For most apps, short access tokens + revocable refresh tokens is the better answer.
Algorithms & Key Choice
The signing algorithm decides who can create and who can verify tokens.
| Family | Type | Keys | Best for |
|---|---|---|---|
| HS256 / HS384 / HS512 | Symmetric (HMAC) | One shared secret signs and verifies | A single service that both issues and checks tokens |
| RS256 / RS384 / RS512 | Asymmetric (RSA) | Private key signs, public key verifies | Many services verify tokens issued elsewhere |
| ES256 / ES384 / ES512 | Asymmetric (ECDSA) | Like RSA but smaller keys/signatures | High-throughput or size-sensitive systems |
With HS256 every verifier needs the secret β fine for one app, risky across many. With RS256 the auth service keeps the private key and distributes only the public key, so downstream microservices can verify without being able to forge:
const fs = require('fs');
const jwt = require('jsonwebtoken');
// Auth service signs with the private key
const privateKey = fs.readFileSync('private.key');
const token = jwt.sign({ userId: user._id }, privateKey,
{ algorithm: 'RS256', expiresIn: '15m' });
// Any resource service verifies with the public key
const publicKey = fs.readFileSync('public.key');
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
Hands-on Exercise
ποΈ Decode a token, then verify one
Objective: Cement how JWTs work by reading one manually and then writing the middleware that trusts it.
Part A β Decode by hand
- Take the header segment
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. - In a Node REPL, run
Buffer.from('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', 'base64url').toString(). - Confirm you get the header JSON. Repeat with the payload segment from Figure 1's token.
Part B β Verify in code
- Write an Express middleware
protectthat reads theAuthorization: Bearerheader. - Verify the token with
jwt.verify, pinningalgorithms: ['HS256']. - On success attach the decoded payload to
req.userand callnext(); otherwise respond 401. - Prove it works: sign a token, call a protected route with it, then tamper one character of the token and confirm it's rejected.
π‘ Hint
Tampering the payload breaks the signature, so jwt.verify throws β which is why your middleware must wrap it in try/catch. Without the algorithms option, some libraries historically accepted a forged "alg": "none" token; pinning the algorithm closes that hole.
β Sample solution (Part B)
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET || 'dev-only-secret';
function protect(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: 'Not authorized' });
try {
req.user = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// Demo
const token = jwt.sign({ id: 42, role: 'user' }, SECRET, { expiresIn: '15m' });
console.log('Send this in the Authorization header:', 'Bearer ' + token);
// Change any character of `token` and the middleware will reject it.
Security Pitfalls
| β Do | β Don't |
|---|---|
Pin allowed algorithms: jwt.verify(t, key, {'{'} algorithms: ['HS256'] {'}'}) | Call jwt.verify(t, key) with no algorithm list (opens the alg: none attack) |
| Use a long, random secret (256+ bits of entropy) | Ship a guessable secret like "secret" or commit it to git |
| Keep access tokens short-lived (15β60 min) | Issue tokens that live for days with no refresh strategy |
| Put only an id and role in the payload | Store passwords, PII, or anything secret in the payload |
Always validate exp, iss, and aud | Trust a decoded token without checking its claims |
| Transmit tokens only over HTTPS | Send tokens over plain HTTP where they can be sniffed |
The single most dangerous mistake is the algorithm confusion / alg: none attack. An older, permissive verifier could be tricked into accepting a token whose header says "no signature," or into verifying an RS256 token using the public key as an HMAC secret. Pinning the expected algorithm on verification defeats both:
// β Dangerous β accepts whatever algorithm the token claims
jwt.verify(token, secret);
// β
Safe β only HS256 is accepted, no matter what the header says
jwt.verify(token, secret, { algorithms: ['HS256'] });
π― Quick Quiz
Question 1: Which part of a JWT guarantees the payload hasn't been tampered with?
Question 2: Why should you never store sensitive data like a password in a JWT payload?
Question 3: When would RS256 be a better choice than HS256?
Summary & Quiz
π Key Takeaways
- A JWT is header.payload.signature; the signature (not encryption) is what makes it trustworthy.
- The payload is readable by anyone β keep it to an id and role, never secrets.
- Protect routes with a verify-once middleware, and layer role checks with an
authorizefactory (401 vs. 403). - Statelessness means tokens can't be revoked early β use short access tokens + revocable refresh tokens, and blacklist only when you must.
- Always pin the algorithm on verification and use a strong secret or RS256 key pair.
π Further Reading
- RFC 7519 β JSON Web Token
- jwt.io β Introduction to JSON Web Tokens
- OWASP β JWT Security Cheat Sheet
π What's Next?
JWTs power the tokens, but who issues them when you delegate login to Google or GitHub? Next, OAuth and OpenID Connect β the frameworks behind "Sign in withβ¦" and single sign-on.
π Nicely done!
You can now build and secure a stateless auth system end to end. Let's see how the big identity providers do it.