← Back to all blogs
JWT Authentication System Design - Step-by-Step Tutorial
Sat Feb 28 202610 minIntermediate

JWT Authentication System Design - Step-by-Step Tutorial

A comprehensive tutorial that walks you through designing a JWT authentication system, from architecture to code implementation and best‑practice security measures.

#jwt#authentication#node.js#express#security#api

Introduction

JSON Web Tokens (JWT) have become the de‑facto standard for stateless authentication in modern web applications. Unlike traditional session‑based approaches, JWTs allow a server to delegate identity verification to a signed token that the client carries on each request. This tutorial guides you through designing a production‑ready JWT authentication system, complete with architectural diagrams, code snippets, and security best practices.

We will use Node.js and Express as the backend framework, and the popular jsonwebtoken library for token handling. By the end of the guide, you will have a clear mental model of how JWTs fit into a microservice architecture, and a ready‑to‑run code base that you can adapt to your own projects.

System Architecture Overview

Before diving into code, it is essential to understand the components that make up a JWT authentication flow. The diagram below outlines a typical three‑tier architecture:

+-------------------+ +----------------------+ +--------------------+ | Client (Web / | ---> | API Gateway / | ---> | Auth Service (JWT | | Mobile App) | | Edge Service | | Issuer) | +-------------------+ +----------------------+ +--------------------+ ^ ^ | | | | | Protected Resources | | +---------------------------------+---------------------+

Key components

  • Client - Stores the JWT in a secure location (e.g., HttpOnly cookie or Secure storage) and includes it in the Authorization: Bearer <token> header for every request.
  • API Gateway - Acts as a reverse proxy. It validates the token before forwarding the request to downstream services, reducing the authentication burden on each microservice.
  • Auth Service - The sole authority that issues and revokes tokens. It validates user credentials, signs the JWT with a private key, and optionally stores a token identifier (jti) for revocation checks.
  • Protected Resources - Any service that requires a verified identity to perform an operation.

Why a Dedicated Auth Service?

  • Separation of concerns - Authentication logic lives in one place, making it easier to audit and rotate keys.
  • Scalability - Tokens are stateless; once issued, services can verify them without contacting the auth service, allowing horizontal scaling.
  • Revocation flexibility - By persisting a jti claim, revocation can be implemented without breaking statelessness.

Token Structure

A JWT consists of three Base64URL‑encoded parts:

  1. Header - Declares the signing algorithm, e.g., { "alg": "RS256", "typ": "JWT" }.
  2. Payload - Contains claims such as sub (subject), iat (issued at), exp (expiration), and custom claims like role.
  3. Signature - Cryptographically signs the header and payload using a secret (HS256) or a private key (RS256).

The following section demonstrates how to generate and sign a token using RSA keys.

Implementing the Token Service

The token service is responsible for two core actions: issuing a JWT after successful credential verification, and refreshing it when the client presents a valid refresh token.

1. Project Setup

bash mkdir jwt-auth-tutorial && cd jwt-auth-tutorial npm init -y npm install express jsonwebtoken bcryptjs dotenv

Create a .env file to store secret keys safely:

env

RSA private/public key pair for RS256 signing

PRIVATE_KEY=./keys/private.key PUBLIC_KEY=./keys/public.key ACCESS_TOKEN_TTL=15m # short‑lived access token REFRESH_TOKEN_TTL=7d # longer‑lived refresh token

Generate RSA keys (execute once):

bash mkdir keys openssl genpkey -algorithm RSA -out keys/private.key -pkeyopt rsa_keygen_bits:2048 openssl rsa -pubout -in keys/private.key -out keys/public.key

2. User Model (Mock)

For brevity we use an in‑memory user store. In production replace this with a database and proper password hashing.

// users.js
const bcrypt = require('bcryptjs');

const users = [ { id: 1, username: 'alice', passwordHash: bcrypt.hashSync('password123', 10), role: 'admin' }, { id: 2, username: 'bob', passwordHash: bcrypt.hashSync('securepwd', 10), role: 'user' } ];

module.exports = { users };

3. Token Generation Logic

// tokenService.js
const fs = require('fs');
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');

const privateKey = fs.readFileSync(process.env.PRIVATE_KEY); const publicKey = fs.readFileSync(process.env.PUBLIC_KEY);

function signAccessToken(payload) { return jwt.sign(payload, privateKey, { algorithm: 'RS256', expiresIn: process.env.ACCESS_TOKEN_TTL, jwtid: uuidv4() // unique identifier for revocation }); }

function signRefreshToken(payload) { return jwt.sign(payload, privateKey, { algorithm: 'RS256', expiresIn: process.env.REFRESH_TOKEN_TTL, jwtid: uuidv4() }); }

function verifyToken(token) { try { return jwt.verify(token, publicKey, { algorithms: ['RS256'] }); } catch (err) { return null; // caller will handle invalid token } }

module.exports = { signAccessToken, signRefreshToken, verifyToken };

4. Login Endpoint

// authRoutes.js
const express = require('express');
const bcrypt = require('bcryptjs');
const { users } = require('./users');
const { signAccessToken, signRefreshToken } = require('./tokenService');

const router = express.Router();

router.post('/login', async (req, res) => { const { username, password } = req.body; const user = users.find(u => u.username === username); if (!user) return res.status(401).json({ message: 'Invalid credentials' });

const passwordMatch = await bcrypt.compare(password, user.passwordHash); if (!passwordMatch) return res.status(401).json({ message: 'Invalid credentials' });

const accessToken = signAccessToken({ sub: user.id, role: user.role }); const refreshToken = signRefreshToken({ sub: user.id });

// Securely set HttpOnly cookie for refresh token res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days in ms });

res.json({ accessToken }); });

module.exports = router;

5. Refresh Endpoint

router.post('/refresh', (req, res) => {
  const { refreshToken } = req.cookies;
  if (!refreshToken) return res.status(401).json({ message: 'Refresh token missing' });

const decoded = verifyToken(refreshToken); if (!decoded) return res.status(401).json({ message: 'Invalid refresh token' });

// Issue a new access token; keep the same subject and role const user = users.find(u => u.id === decoded.sub); if (!user) return res.status(401).json({ message: 'User not found' });

const newAccessToken = signAccessToken({ sub: user.id, role: user.role }); res.json({ accessToken: newAccessToken }); });

With these routes, the token service can issue, refresh, and verify JWTs while keeping private keys isolated from the rest of the application.

Securing API Endpoints

Once the token service is operational, every protected route must ensure that a valid access token accompanies the request. The typical pattern is a middleware that extracts the token, validates it, and attaches the decoded payload to req.user for downstream handlers.

Middleware Implementation

// authMiddleware.js
const { verifyToken } = require('./tokenService');

function authenticate(req, res, next) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; // Bearer <token> if (!token) return res.sendStatus(401);

const payload = verifyToken(token); if (!payload) return res.sendStatus(403); // token invalid or expired

// Attach user info to the request object req.user = { id: payload.sub, role: payload.role, jti: payload.jti }; next(); }

module.exports = { authenticate };

Role‑Based Access Control (RBAC)

For APIs that expose different functionalities based on user roles, add a simple authorization layer.

function authorize(allowedRoles = []) {
  return (req, res, next) => {
    if (!allowedRoles.includes(req.user.role)) {
      return res.sendStatus(403);
    }
    next();
  };
}

module.exports = { authorize };

Sample Protected Route

// protectedRoutes.js
const express = require('express');
const { authenticate } = require('./authMiddleware');
const { authorize } = require('./authMiddleware');

const router = express.Router();

router.get('/admin/dashboard', authenticate, authorize(['admin']), (req, res) => { res.json({ message: Welcome admin #${req.user.id} }); });

router.get('/profile', authenticate, (req, res) => { res.json({ message: User profile for ID ${req.user.id} }); });

module.exports = router;

Token Revocation Strategy

Although JWTs are stateless, revocation is achievable by storing revoked jti values in a fast data store (e.g., Redis) with an expiration equal to the token's TTL.

// revocationService.js (simplified)
const redis = require('redis');
const client = redis.createClient();

async function revokeToken(jti, ttlSeconds) { await client.setex(revoked:${jti}, ttlSeconds, 'true'); }

async function isTokenRevoked(jti) { const result = await client.get(revoked:${jti}); return result === 'true'; }

module.exports = { revokeToken, isTokenRevoked };

Integrate the check into the authentication middleware:

const { isTokenRevoked } = require('./revocationService');

async function authenticate(req, res, next) { // ... token extraction & verification as before const payload = verifyToken(token); if (!payload) return res.sendStatus(403);

const revoked = await isTokenRevoked(payload.jti); if (revoked) return res.sendStatus(401);

// continue with attaching user req.user = { id: payload.sub, role: payload.role, jti: payload.jti }; next(); }

By doing so, compromised tokens can be invalidated instantly without waiting for natural expiration.

Security Best Practices Checklist

  • Use asymmetric keys (RS256) - Allows key rotation without downtime.
  • Set short access‑token lifetimes - Limits exposure if a token is stolen.
  • Store refresh tokens in HttpOnly, Secure cookies - Prevents XSS access.
  • Validate aud and iss claims - Guarantees token originates from your service.
  • Enable CORS with strict origins - Avoids CSRF attacks.
  • Log authentication events - Helps with forensic analysis.

Following these practices ensures a resilient authentication layer that scales across microservices.

FAQs

Q1: Why not store JWTs in localStorage?

A: LocalStorage is vulnerable to cross‑site scripting (XSS) attacks because JavaScript can read its contents. An attacker who injects malicious script could exfiltrate the token and hijack a user’s session. Using HttpOnly, Secure cookies mitigates this risk because the browser restricts JavaScript access.


Q2: How does token revocation work with a stateless JWT?

A: Revocation introduces a tiny stateful component-a blacklist of token identifiers (jti). When a token is revoked, its jti is stored in a fast cache (e.g., Redis) with an expiration matching the token’s TTL. The authentication middleware checks this list on each request. This approach retains most benefits of stateless JWTs while providing instant revocation capability.


Q3: Should I embed user permissions directly in the JWT payload?

A: Including role information (e.g., role: 'admin') is common and reduces database lookups. However, embedding fine‑grained permissions can cause token bloat and requires careful updates when permissions change. A balanced strategy is to store a high‑level role in the token and fetch detailed permission sets from a database if the role changes during a session.


Q4: What is the recommended key rotation process?

A: Generate a new RSA key pair, update the public key used by services for verification, and keep the old private key active for a grace period (usually one token rotation cycle). After all tokens signed with the old key expire, retire the old key. Automating this with a configuration service (e.g., AWS Secrets Manager) helps avoid manual errors.


Q5: Can I use JWTs for single‑page application (SPA) authentication?

A: Yes, but follow the cookie‑based approach described in this tutorial. Store the access token in memory (React state, Redux) and keep the refresh token in an HttpOnly cookie. Refresh the access token silently before it expires to maintain a seamless user experience.

Conclusion

Designing a robust JWT authentication system involves more than merely signing a token. A well‑architected solution separates concerns across an Auth Service, API Gateway, and protected microservices, while employing RSA key pairs for asymmetric signing, short‑lived access tokens, and securely stored refresh tokens.

The step‑by‑step implementation presented here demonstrates how to:

  1. Generate RSA keys and configure environment variables.
  2. Build a token service that issues, refreshes, and verifies JWTs.
  3. Secure endpoints with authentication and role‑based authorization middleware.
  4. Implement token revocation using a lightweight blacklist.
  5. Follow security best practices that mitigate common attacks such as XSS, CSRF, and token replay.

By integrating these patterns into your backend stack, you gain a scalable, maintainable, and secure authentication layer that can evolve alongside your application’s growth. Remember to monitor key rotation, audit token usage, and stay current with emerging security standards to keep your system resilient over time.