JWT Authentication in Express
Reviewed & published by Brayan K
JWT authentication is a stateless way to prove who a user is: the server signs a token at login, the client sends it back on every request, and a middleware verifies the signature before letting the request reach a protected route.
Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll sign and verify tokens with jsonwebtoken, hash passwords with bcrypt, build a reusable auth middleware that reads the Bearer header, protect routes, and understand expiry and refresh tokens — the exact pattern real Express APIs use to lock things down.
What You'll Learn in This Lesson
- How to sign and verify tokens with jwt.sign and jwt.verify
- How to hash and check passwords safely with bcrypt
- How a login endpoint issues a token the client stores
- How to write an auth middleware that reads the Bearer header
- How to protect routes and attach the user to the request
- How token expiry works and why refresh tokens exist
1️⃣ Signing & Verifying Tokens
Authentication with JWTs comes down to two functions. jwt.sign(payload, secret, options) takes the data you trust (a user id, a role), bundles it with a header and an expiry, and produces a signed string. jwt.verify(token, secret) does the reverse: it checks the signature against your secret and the expiry, and either returns the decoded payload or throws.
The magic is the signature. Because it's computed from the payload and your secret, nobody can change the payload (say, bump their role to admin) without invalidating the token. That's why JWTs are stateless — the server doesn't store sessions; it just re-checks the signature on every request.
// Install once: npm install jsonwebtoken
const jwt = require('jsonwebtoken');
// In real apps this lives in an env var, never in source code.
const SECRET = 'super-secret-key';
// sign(payload, secret, options) -> a compact, URL-safe string.
// The payload is the data you're willing to trust later (id, role...).
const token = jwt.sign({ id: 42, role: 'admin' }, SECRET, { expiresIn: '1h' });
console.log('Token (first 20 chars):', token.slice(0, 20) + '...');
// verify() decodes the token AND checks the signature + expiry.
// It throws if the token was tampered with or has expired.
const decoded = jwt.verify(token, SECRET);
console.log('Decoded id:', decoded.id);
console.log('Decoded role:', decoded.role);
console.log('Has expiry (exp) field?', typeof decoded.exp === 'number');2️⃣ Hashing Passwords with bcrypt
Before you can issue a token at login, you have to verify the password — and you must never store passwords in plain text. bcrypt turns a password into a slow, salted hash. You store the hash, and at login you call bcrypt.compare(candidate, storedHash), which returns true or false.
The second argument to hash is the cost factor. A value of 10 means bcrypt does 2¹⁰ rounds of work — slow enough to make brute-forcing painful, fast enough for a login. Notice the wrong password returns false without you ever decrypting anything.
// Install once: npm install bcryptjs
const bcrypt = require('bcryptjs');
async function main() {
const plain = 'hunter2';
// hash(password, costFactor). 10 is a good default — higher = slower = safer.
const hash = await bcrypt.hash(plain, 10);
console.log('Hash starts with:', hash.slice(0, 7)); // $2b$10$ = bcrypt + cost
console.log('Hash length:', hash.length);
// NEVER compare passwords with ===. compare() re-hashes the candidate
// with the stored salt and tells you if it matches.
console.log('Correct password matches?', await bcrypt.compare('hunter2', hash));
console.log('Wrong password matches? ', await bcrypt.compare('wrong', hash));
}
main();3️⃣ Login + an Auth Middleware
Now put it together. A login function checks credentials and signs a token. An auth middleware runs before protected routes: it reads the Authorization: Bearer <token> header, verifies the token, and attaches the decoded claims to the request. The runnable version below uses plain objects so you can see the logic without a server.
const jwt = require('jsonwebtoken');
const SECRET = 'demo-secret';
// Pretend "database". In real life the password is bcrypt-hashed (see above).
const users = [{ id: 1, email: 'ada@example.com', passwordOk: true }];
// 1) LOGIN: check credentials, then ISSUE a token the client stores.
function login(email) {
const user = users.find(u => u.email === email);
if (!user) throw new Error('No such user');
return jwt.sign({ sub: user.id, email: user.email }, SECRET, { expiresIn: '15m' });
}
// 2) AUTH MIDDLEWARE: read "Authorization: Bearer <token>" and verify it.
// In Express this would be (req, res, next) and set req.user = claims.
function authMiddleware(headers) {
const header = headers.authorization || '';
const [scheme, token] = header.split(' ');
if (scheme !== 'Bearer' || !token) throw new Error('Missing Bearer token');
return jwt.verify(token, SECRET); // returns the decoded payload, or throws
}
// --- Simulate one request cycle ---
const token = login('ada@example.com');
console.log('Issued token?', token.length > 50);
const req = { headers: { authorization: 'Bearer ' + token } };
const claims = authMiddleware(req.headers);
console.log('Authenticated user id:', claims.sub);
console.log('Authenticated email: ', claims.email);
// A request with no token is rejected before it reaches your route:
try { authMiddleware({}); } catch (e) { console.log('No token ->', e.message); }Here's the same idea wired into a real Express app, with registration, login, a reusable requireAuth middleware, and a protected /me route. This is the shape you'll copy into your own projects:
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const app = express();
app.use(express.json());
const SECRET = process.env.JWT_SECRET; // load from the environment
const users = []; // stand-in store
// POST /register — hash the password before saving it.
app.post('/register', async (req, res) => {
const { email, password } = req.body;
const hash = await bcrypt.hash(password, 10);
users.push({ id: users.length + 1, email, hash });
res.status(201).json({ ok: true });
});
// POST /login — verify the password, then issue a token.
app.post('/login', async (req, res) => {
const user = users.find(u => u.email === req.body.email);
if (!user || !(await bcrypt.compare(req.body.password, user.hash)))
return res.status(401).json({ error: 'Invalid credentials' });
const token = jwt.sign({ sub: user.id }, SECRET, { expiresIn: '15m' });
res.json({ token });
});
// Reusable middleware: protect any route by putting it before the handler.
function requireAuth(req, res, next) {
const [scheme, token] = (req.headers.authorization || '').split(' ');
if (scheme !== 'Bearer' || !token)
return res.status(401).json({ error: 'No token' });
try {
req.user = jwt.verify(token, SECRET); // attach the claims for the route
next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// A protected route — only reachable with a valid token.
app.get('/me', requireAuth, (req, res) => {
res.json({ id: req.user.sub });
});
app.listen(3000);Tokens don't last forever, and that's the point. expiresIn stamps an expiry into the token; once it passes, verify throws a TokenExpiredError. A token signed with the wrong secret throws invalid signature. Short-lived access tokens limit the damage of a stolen one — and a longer-lived refresh token lets the client quietly get a fresh access token without re-entering a password.
const jwt = require('jsonwebtoken');
const SECRET = 's';
// Make a token that's already expired (expires 1 second in the PAST).
const token = jwt.sign({ id: 7 }, SECRET, { expiresIn: -1 });
try {
jwt.verify(token, SECRET);
} catch (err) {
console.log('Name:', err.name);
console.log('Message:', err.message);
}
// A token signed with a DIFFERENT secret also fails verification —
// this is what stops an attacker from forging tokens.
const forged = jwt.sign({ id: 7 }, 'attacker-secret');
try {
jwt.verify(forged, SECRET);
} catch (err) {
console.log('Forged ->', err.message);
}Your turn. Fill in the two ___ blanks, follow the 👉 hints, then run it.
const jwt = require('jsonwebtoken');
const SECRET = 'lesson-secret';
// 🎯 YOUR TURN — replace each ___ then run it.
// 1) Sign a token whose payload is { user: 'sam' }, valid for 2 hours.
const token = jwt.sign(___, SECRET, { expiresIn: '2h' }); // 👉 { user: 'sam' }
// 2) Verify the token with the SAME secret to get the payload back.
const decoded = jwt.verify(token, ___); // 👉 SECRET
console.log('Welcome back, ' + decoded.user);
// ✅ Expected output:
// Welcome back, samMini-Challenge: A Tiny Route Guard
No blanks this time — just a brief and an outline. Write it yourself, run it, and check your output against the example in the comments. This ties together signing, the Bearer header, and verification in one short program.
const jwt = require('jsonwebtoken');
const SECRET = 'mini-secret';
// 🎯 MINI-CHALLENGE: a tiny "protected route" check.
// 1. Sign a token for { sub: 99, role: 'editor' } (no expiry needed).
// 2. Write a guard(headers) function that reads "Authorization: Bearer <token>",
// verifies it, and RETURNS the role — or throws if there's no token.
// 3. Call it with a proper "Bearer <token>" header and print the role.
//
// ✅ Example output:
// Role from token: editor
// your code hereCommon Errors (and the fix)
- "JsonWebTokenError: invalid signature" — The secret you verified with doesn't match the one you signed with (often a typo or a different process.env.JWT_SECRET between environments). Use one secret, loaded the same way everywhere.
- "TokenExpiredError: jwt expired" — The token's exp time has passed. This is expected behaviour; the client should get a new token via your refresh flow, not just re-sign with a longer expiry.
- "jwt must be provided" — You passed undefined to verify. Usually the header is missing or you forgot to split off the "Bearer " prefix before the token.
- Comparing passwords with === — A stored bcrypt hash never equals the plain password. Always use await bcrypt.compare(plain, hash).
- Secrets in source control — Hard-coding SECRET and pushing it to GitHub leaks it forever. Use environment variables and a .gitignored .env file.
Pro Tips
- 💡 Keep payloads tiny: store an id and maybe a role — not the whole user. The token travels on every request, and its contents are readable by anyone.
- 💡 Short access, long refresh: 15-minute access tokens plus a revocable refresh token give you both convenience and the ability to log users out.
- 💡 One middleware, many routes: write requireAuth once and drop it in front of any route that needs a logged-in user.
📋 Quick Reference — JWT & bcrypt
| Task | Syntax |
|---|---|
| Sign a token | jwt.sign(payload, SECRET, { expiresIn: '15m' }) |
| Verify a token | jwt.verify(token, SECRET) |
| Read the header | req.headers.authorization |
| Split off Bearer | header.split(' ')[1] |
| Hash a password | await bcrypt.hash(pw, 10) |
| Check a password | await bcrypt.compare(pw, hash) |
🎉 Lesson Complete!
- ✅ jwt.sign issues a signed token; jwt.verify checks the signature and expiry
- ✅ JWTs are signed, not encrypted — keep payloads small and non-secret
- ✅ bcrypt.hash / bcrypt.compare store and check passwords safely
- ✅ An auth middleware reads Authorization: Bearer <token> and attaches the claims to req.user
- ✅ expiresIn sets the lifetime; refresh tokens renew access without a re-login
- ✅ Load your signing secret from an environment variable, never from source
Practice quiz
Is a standard JWT encrypted or signed?
- Encrypted, so the payload is hidden
- Both encrypted and compressed
- Signed, so anyone can read the payload
- Neither; it is plain text only
Answer: Signed, so anyone can read the payload. A JWT is signed, not encrypted. Anyone with the token can decode and read the payload, so never put secrets in it.
What does jwt.sign(payload, secret, options) return?
- A compact URL-safe token string
- A boolean
- The decoded payload object
- A Buffer of random bytes
Answer: A compact URL-safe token string. sign() produces a compact, URL-safe string made of three base64url parts separated by dots.
What does jwt.verify(token, secret) do?
- Only decodes without checking anything
- Generates a new token
- Hashes a password
- Checks the signature and expiry, returning the payload or throwing
Answer: Checks the signature and expiry, returning the payload or throwing. verify() validates the signature and expiry; it throws if the token was tampered with or has expired.
How many dot-separated parts does a JWT have?
- Two: header and body
- Three: header, payload, signature
- One single block
- Four parts
Answer: Three: header, payload, signature. A JWT has three base64url parts: header, payload, and signature, separated by dots.
Why hash passwords with bcrypt instead of storing them plainly?
- A leak would expose every account, and bcrypt is slow and salted
- bcrypt makes login faster
- It encrypts the database file
- Plain passwords are fine if short
Answer: A leak would expose every account, and bcrypt is slow and salted. Never store plain passwords. bcrypt is deliberately slow and adds a per-password salt, making guesses expensive.
What does bcrypt.compare(candidate, storedHash) return?
- The original password
- A new hash
- Whether the candidate matches the stored hash
- The salt used
Answer: Whether the candidate matches the stored hash. compare() re-hashes the candidate with the stored salt and tells you if it matches; never use === on passwords.
In an Authorization header, what scheme prefixes the token?
- Basic
- Bearer
- Token
- JWT
Answer: Bearer. The common pattern is 'Authorization: Bearer <token>', so the middleware splits off the Bearer scheme.
What error name does jwt.verify throw for an expired token?
- ExpiredJwtException
- AuthError
- TimeoutError
- TokenExpiredError
Answer: TokenExpiredError. An expired token causes verify() to throw a TokenExpiredError with message 'jwt expired'.
What happens if a token is signed with a different secret than verify uses?
- It still verifies fine
- Verification fails with an invalid signature error
- The payload is returned anyway
- Node crashes
Answer: Verification fails with an invalid signature error. A wrong secret produces 'invalid signature', which is what stops attackers from forging tokens.
Why use short-lived access tokens plus a refresh token?
- It removes the need for any secret
- It makes tokens unencrypted
- A stolen access token is useful only briefly, and refresh tokens can be revoked
- It avoids using HTTPS
Answer: A stolen access token is useful only briefly, and refresh tokens can be revoked. Short-lived access tokens limit damage if stolen; a separately stored refresh token mints new ones and can be revoked.
Continue this course
- Previous: Request Validation (Zod & express-validator)
- Next: File Uploads with Multer — Handle multipart/form-data with disk and memory storage and limits
- Quick reference: Node.js cheat sheet
Frequently asked questions
What exactly is a JWT and is it encrypted?
A JWT (JSON Web Token) is a compact string with three base64url parts separated by dots: a header, a payload, and a signature. It is SIGNED, not encrypted — anyone who has the token can decode and read the payload, so never put secrets like passwords in it. The signature is what matters: it's an HMAC (or RSA/ECDSA) of the header and payload using your server's secret, so if even one character of the payload is changed the signature no longer matches and jwt.verify throws. Put only non-sensitive identifiers (a user id, a role) in the payload.
Should I store the JWT in localStorage or a cookie?
Both are used in practice with trade-offs. localStorage is simple and you send the token yourself in an Authorization: Bearer header, but it's readable by any JavaScript on the page, so an XSS bug can steal it. An httpOnly, Secure, SameSite cookie is not readable by JavaScript (safer against XSS) but you must defend against CSRF. For APIs consumed by a separate frontend or mobile app, the Bearer header pattern is common; for classic same-site web apps, httpOnly cookies are a strong default. Either way, keep access tokens short-lived.
Why use a refresh token instead of one long-lived token?
Access tokens should be short-lived (often 5–15 minutes) so that a stolen token is useful only briefly. But you don't want to force users to log in every 15 minutes, so you also issue a long-lived refresh token (stored more securely, e.g. an httpOnly cookie or a server record). When the access token expires, the client quietly POSTs the refresh token to a /refresh endpoint and gets a new access token. Because refresh tokens are stored server-side or can be tracked, you can revoke them (on logout or suspicious activity), which you cannot easily do with a plain stateless JWT.
Why hash passwords with bcrypt instead of just storing them or using SHA-256?
You must never store plain passwords — a database leak would expose every account. Fast hashes like SHA-256 are also a poor choice for passwords because an attacker can try billions of guesses per second. bcrypt is deliberately slow and includes a built-in random salt per password and a tunable cost factor, so each guess is expensive and identical passwords produce different hashes. You store the bcrypt hash and later call bcrypt.compare(candidate, storedHash) to check a login, which is exactly what the examples in this lesson do.