Skip to main content

🎫 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.

flowchart TD A[User] -->|Submit credentials| B[Auth server] B -->|Verify credentials| B B -->|Sign a JWT| C[Signed token] C -->|Return token| A A -->|Send token on each request| D[API server] D -->|Verify signature| D D -->|Trust the claims| E[Protected resource] E --> A
πŸ’‘ 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
The three parts of a JWT A JWT is made of a header, a payload, and a signature, each base64url encoded and joined by dots. The signature is computed from the header and payload plus a secret. Header alg: HS256 typ: JWT which algorithm? Payload sub, role iat, exp the claims (readable!) Signature HMAC(header + payload, secret) proves integrity xxxxx . yyyyy . zzzzz one compact string sent in the Authorization header
Figure 1 β€” The header says how the token is signed, the payload carries the claims, and the signature ties them together with a secret so any change is detectable.

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:

sequenceDiagram participant C as Client participant S as Server participant DB as Token store C->>S: Login S-->>C: Access token (15m) + refresh token (7d) Note over C,S: Access token expires C->>S: Request with expired access token S-->>C: 401 Unauthorized C->>S: POST /refresh (refresh token) S->>DB: Validate + rotate DB-->>S: Valid S-->>C: New access token (+ new refresh token)
// 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.

FamilyTypeKeysBest for
HS256 / HS384 / HS512Symmetric (HMAC)One shared secret signs and verifiesA single service that both issues and checks tokens
RS256 / RS384 / RS512Asymmetric (RSA)Private key signs, public key verifiesMany services verify tokens issued elsewhere
ES256 / ES384 / ES512Asymmetric (ECDSA)Like RSA but smaller keys/signaturesHigh-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

  1. Take the header segment eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
  2. In a Node REPL, run Buffer.from('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', 'base64url').toString().
  3. Confirm you get the header JSON. Repeat with the payload segment from Figure 1's token.

Part B β€” Verify in code

  1. Write an Express middleware protect that reads the Authorization: Bearer header.
  2. Verify the token with jwt.verify, pinning algorithms: ['HS256'].
  3. On success attach the decoded payload to req.user and call next(); otherwise respond 401.
  4. 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 payloadStore passwords, PII, or anything secret in the payload
Always validate exp, iss, and audTrust a decoded token without checking its claims
Transmit tokens only over HTTPSSend 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 authorize factory (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

πŸš€ 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.