JWT
Security

Authentication with JWT: Complete Implementation Guide

Mayur Dabhi
Mayur Dabhi
February 21, 2026
20 min read

Your Express API works fine with one server and cookie sessions. Then a mobile app needs to call it, or you put a second instance behind a load balancer, and suddenly users get logged out at random because the session lives in one process's memory. That's usually the point where someone suggests JSON Web Tokens.

This post walks through how a JWT is built, the login and refresh flow, a full Node.js + Express implementation with access and refresh tokens, the frontend side, and the security mistakes that show up most often. I'll also cover revocation, which is where JWTs get awkward.

Why people reach for JWT

The server can verify a JWT with just a secret or public key, so it doesn't need to look up a session on every request. That makes it easier to run several API instances or split auth across services. The cost is that you lose easy logout, which we'll get to.

What is a JSON Web Token?

Think of a JWT as a signed note: it carries a few facts about the user (the "claims"), and anyone holding the right key can check that nobody changed them, without calling back to whoever issued it. The format is compact and URL-safe, so it fits in an HTTP header.

A JWT consists of three parts, separated by dots (.):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik1heXVyIERhYmhpIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header
Algorithm & token type
Payload
Claims (user data)
Signature
Verification hash

Anatomy of a JWT

JWT Structure Breakdown HEADER { "alg": "HS256", "typ": "JWT" } β†’ Base64 eyJhbGciOiJIUzI1... PAYLOAD { "sub": "user123", "name": "Mayur", "iat": 1708473600 } β†’ Base64 eyJzdWIiOiJ1c2Vy... SIGNATURE HMACSHA256( base64(header) + "." + base64(payload) , secret_key) ↓ SflKxwRJSMeKKF... eyJhbGc....eyJzdWI....SflKxw...

How the three parts of a JWT are created and combined

JWT Authentication Flow

A lot of JWT bugs come from getting this flow slightly wrong, so here's the whole thing from login to a protected request:

JWT Authentication Flow CLIENT πŸ’» Browser/App SERVER πŸ–₯️ API Server DATABASE πŸ—„οΈ User Store 1 POST /login {email, password} 2 Verify credentials User data βœ“ 3 Generate JWT 4 Return JWT {token: "eyJhbG..."} 5 Store token Subsequent Requests: β€’ Include JWT in header: Authorization: Bearer eyJhbG... β€’ Server verifies signature

Complete JWT authentication flow from login to token storage

πŸ”
1. Login

User sends credentials

βœ…
2. Verify

Server validates user

🎫
3. Generate

Create signed JWT

πŸ“€
4. Return

Send token to client

πŸ’Ύ
5. Store

Client saves token

πŸ”„
6. Use

Include in requests

Implementation: Node.js + Express

The example below covers registration, login, token generation, refresh, and protected routes. It uses an in-memory user store so you can run it without a database.

Step 1: Project Setup

bash
# Initialize project
mkdir jwt-auth-demo && cd jwt-auth-demo
npm init -y

# Install dependencies
npm install express jsonwebtoken bcryptjs dotenv cookie-parser
npm install -D nodemon

Step 2: Environment Configuration

.env
# JWT Configuration
JWT_SECRET=your-super-secret-key-min-32-chars-long
JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=your-refresh-secret-key-different-from-access
JWT_REFRESH_EXPIRES_IN=7d

# Server Configuration
PORT=3000
NODE_ENV=development
Security Warning

Don't commit your .env file. Add it to .gitignore and use your hosting platform's environment variables in production. The secret should be at least 256 bits of random data; openssl rand -base64 32 gives you one. A memorable phrase padded to 32 characters doesn't count.

Step 3: User Model & Authentication Logic

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

// In-memory store (use MongoDB/PostgreSQL in production)
const users = new Map();

class User {
    constructor(id, email, password, name) {
        this.id = id;
        this.email = email;
        this.password = password;
        this.name = name;
        this.createdAt = new Date();
    }

    // Hash password before saving
    static async create(email, password, name) {
        const existingUser = Array.from(users.values())
            .find(u => u.email === email);
        
        if (existingUser) {
            throw new Error('User already exists');
        }

        const hashedPassword = await bcrypt.hash(password, 12);
        const id = `user_${Date.now()}`;
        const user = new User(id, email, hashedPassword, name);
        users.set(id, user);
        
        return user;
    }

    // Find user by email
    static findByEmail(email) {
        return Array.from(users.values())
            .find(u => u.email === email);
    }

    // Find user by ID
    static findById(id) {
        return users.get(id);
    }

    // Verify password
    async comparePassword(candidatePassword) {
        return bcrypt.compare(candidatePassword, this.password);
    }

    // Return safe user object (no password)
    toJSON() {
        return {
            id: this.id,
            email: this.email,
            name: this.name,
            createdAt: this.createdAt
        };
    }
}

module.exports = User;

Step 4: JWT Utilities

utils/jwt.js
const jwt = require('jsonwebtoken');

// Generate Access Token (short-lived)
const generateAccessToken = (user) => {
    return jwt.sign(
        { 
            id: user.id,
            email: user.email,
            type: 'access'
        },
        process.env.JWT_SECRET,
        { 
            expiresIn: process.env.JWT_EXPIRES_IN || '15m',
            issuer: 'your-app-name',
            audience: 'your-app-users'
        }
    );
};

// Generate Refresh Token (long-lived)
const generateRefreshToken = (user) => {
    return jwt.sign(
        { 
            id: user.id,
            type: 'refresh'
        },
        process.env.JWT_REFRESH_SECRET,
        { 
            expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
            issuer: 'your-app-name'
        }
    );
};

// Verify Access Token
const verifyAccessToken = (token) => {
    try {
        return jwt.verify(token, process.env.JWT_SECRET);
    } catch (error) {
        if (error.name === 'TokenExpiredError') {
            throw new Error('Token expired');
        }
        if (error.name === 'JsonWebTokenError') {
            throw new Error('Invalid token');
        }
        throw error;
    }
};

// Verify Refresh Token
const verifyRefreshToken = (token) => {
    try {
        return jwt.verify(token, process.env.JWT_REFRESH_SECRET);
    } catch (error) {
        throw new Error('Invalid refresh token');
    }
};

// Decode token without verification (for debugging)
const decodeToken = (token) => {
    return jwt.decode(token);
};

module.exports = {
    generateAccessToken,
    generateRefreshToken,
    verifyAccessToken,
    verifyRefreshToken,
    decodeToken
};

Step 5: Authentication Middleware

middleware/auth.js
const { verifyAccessToken } = require('../utils/jwt');
const User = require('../models/User');

// Protect routes - require valid JWT
const protect = async (req, res, next) => {
    try {
        let token;

        // Get token from Authorization header
        if (req.headers.authorization?.startsWith('Bearer')) {
            token = req.headers.authorization.split(' ')[1];
        }
        // Or from cookies (for web apps)
        else if (req.cookies?.accessToken) {
            token = req.cookies.accessToken;
        }

        if (!token) {
            return res.status(401).json({
                success: false,
                message: 'Access denied. No token provided.'
            });
        }

        // Verify token
        const decoded = verifyAccessToken(token);

        // Get user from database
        const user = User.findById(decoded.id);
        
        if (!user) {
            return res.status(401).json({
                success: false,
                message: 'User no longer exists.'
            });
        }

        // Attach user to request
        req.user = user;
        next();

    } catch (error) {
        const message = error.message === 'Token expired' 
            ? 'Token expired. Please refresh your token.'
            : 'Invalid token. Please log in again.';

        return res.status(401).json({
            success: false,
            message,
            code: error.message === 'Token expired' ? 'TOKEN_EXPIRED' : 'INVALID_TOKEN'
        });
    }
};

// Optional auth - attach user if token present, continue otherwise
const optionalAuth = async (req, res, next) => {
    try {
        let token = req.headers.authorization?.split(' ')[1] || req.cookies?.accessToken;
        
        if (token) {
            const decoded = verifyAccessToken(token);
            req.user = User.findById(decoded.id);
        }
    } catch (error) {
        // Token invalid but we continue anyway
        req.user = null;
    }
    next();
};

module.exports = { protect, optionalAuth };
Debugging "jwt malformed"

If your logs show JsonWebTokenError: jwt malformed, check what the client actually sent. Often the header is literally Authorization: Bearer undefined or Bearer null, because the frontend read the token from storage before login finished. jwt expired and invalid signature are different problems: the first is normal and should trigger a refresh, the second usually means two environments are signing with different secrets.

Step 6: Auth Controller

controllers/authController.js
const User = require('../models/User');
const { 
    generateAccessToken, 
    generateRefreshToken,
    verifyRefreshToken 
} = require('../utils/jwt');

// Cookie options
const cookieOptions = {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
};

// @desc    Register new user
// @route   POST /api/auth/register
const register = async (req, res) => {
    try {
        const { email, password, name } = req.body;

        // Validation
        if (!email || !password || !name) {
            return res.status(400).json({
                success: false,
                message: 'Please provide email, password, and name'
            });
        }

        if (password.length < 8) {
            return res.status(400).json({
                success: false,
                message: 'Password must be at least 8 characters'
            });
        }

        // Create user
        const user = await User.create(email, password, name);

        // Generate tokens
        const accessToken = generateAccessToken(user);
        const refreshToken = generateRefreshToken(user);

        // Set refresh token in cookie
        res.cookie('refreshToken', refreshToken, cookieOptions);

        res.status(201).json({
            success: true,
            message: 'User registered successfully',
            data: {
                user: user.toJSON(),
                accessToken,
                expiresIn: process.env.JWT_EXPIRES_IN || '15m'
            }
        });

    } catch (error) {
        res.status(400).json({
            success: false,
            message: error.message
        });
    }
};

// @desc    Login user
// @route   POST /api/auth/login
const login = async (req, res) => {
    try {
        const { email, password } = req.body;

        // Validation
        if (!email || !password) {
            return res.status(400).json({
                success: false,
                message: 'Please provide email and password'
            });
        }

        // Find user
        const user = User.findByEmail(email);
        
        if (!user) {
            return res.status(401).json({
                success: false,
                message: 'Invalid credentials'
            });
        }

        // Check password
        const isMatch = await user.comparePassword(password);
        
        if (!isMatch) {
            return res.status(401).json({
                success: false,
                message: 'Invalid credentials'
            });
        }

        // Generate tokens
        const accessToken = generateAccessToken(user);
        const refreshToken = generateRefreshToken(user);

        // Set refresh token in cookie
        res.cookie('refreshToken', refreshToken, cookieOptions);

        res.json({
            success: true,
            message: 'Login successful',
            data: {
                user: user.toJSON(),
                accessToken,
                expiresIn: process.env.JWT_EXPIRES_IN || '15m'
            }
        });

    } catch (error) {
        res.status(500).json({
            success: false,
            message: 'Server error'
        });
    }
};

// @desc    Refresh access token
// @route   POST /api/auth/refresh
const refreshToken = async (req, res) => {
    try {
        const token = req.cookies.refreshToken || req.body.refreshToken;

        if (!token) {
            return res.status(401).json({
                success: false,
                message: 'Refresh token required'
            });
        }

        // Verify refresh token
        const decoded = verifyRefreshToken(token);

        // Get user
        const user = User.findById(decoded.id);

        if (!user) {
            return res.status(401).json({
                success: false,
                message: 'User not found'
            });
        }

        // Generate new access token
        const accessToken = generateAccessToken(user);

        res.json({
            success: true,
            data: {
                accessToken,
                expiresIn: process.env.JWT_EXPIRES_IN || '15m'
            }
        });

    } catch (error) {
        res.status(401).json({
            success: false,
            message: 'Invalid refresh token'
        });
    }
};

// @desc    Logout user
// @route   POST /api/auth/logout
const logout = (req, res) => {
    res.cookie('refreshToken', '', {
        httpOnly: true,
        expires: new Date(0)
    });

    res.json({
        success: true,
        message: 'Logged out successfully'
    });
};

// @desc    Get current user
// @route   GET /api/auth/me
const getMe = (req, res) => {
    res.json({
        success: true,
        data: {
            user: req.user.toJSON()
        }
    });
};

module.exports = { register, login, refreshToken, logout, getMe };

Step 7: Express Server Setup

server.js
require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const { protect } = require('./middleware/auth');
const authController = require('./controllers/authController');

const app = express();

// Middleware
app.use(express.json());
app.use(cookieParser());

// Auth Routes
app.post('/api/auth/register', authController.register);
app.post('/api/auth/login', authController.login);
app.post('/api/auth/refresh', authController.refreshToken);
app.post('/api/auth/logout', authController.logout);
app.get('/api/auth/me', protect, authController.getMe);

// Protected Route Example
app.get('/api/protected', protect, (req, res) => {
    res.json({
        success: true,
        message: 'You have access to this protected resource!',
        user: req.user.toJSON()
    });
});

// Public Route
app.get('/api/public', (req, res) => {
    res.json({
        success: true,
        message: 'This is a public endpoint'
    });
});

// Error Handler
app.use((err, req, res, next) => {
    console.error(err.stack);
    res.status(500).json({
        success: false,
        message: 'Internal server error'
    });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
    console.log(`πŸš€ Server running on port ${PORT}`);
});

Access Tokens vs Refresh Tokens

The code above issues two tokens, not one. A single long-lived token is convenient until it leaks, and then the attacker has it for days. Splitting it in two keeps the token that travels on every request short-lived:

Access Token

Short-lived token used to access protected resources. Sent with every API request.

⏱️ Lifetime: 15 minutes
  • Contains user claims
  • Stored in memory (frontend)
  • If stolen, limited damage window

Refresh Token

Long-lived token used only to obtain new access tokens. Stored securely.

⏱️ Lifetime: 7 days
  • Minimal payload (just user ID)
  • Stored in httpOnly cookie
  • Can be revoked server-side
Token Refresh Flow Access Token 15 min Expired Refresh Access Token 15 min Expired Refresh Access Token 15 min ... Refresh Token (7 days) Access Token (short-lived) Refresh Token (long-lived)

Access tokens expire frequently; refresh tokens are used to get new access tokens

Frontend Implementation

On the client you need two things: attach the access token to requests, and quietly get a new one when the API returns 401. Three versions below:

// hooks/useAuth.js
import { createContext, useContext, useState, useCallback } from 'react';
import api from '../utils/api';

const AuthContext = createContext(null);

export const AuthProvider = ({ children }) => {
    const [user, setUser] = useState(null);
    const [loading, setLoading] = useState(true);

    const login = async (email, password) => {
        const { data } = await api.post('/auth/login', { email, password });
        setUser(data.data.user);
        localStorage.setItem('accessToken', data.data.accessToken);
        return data;
    };

    const logout = async () => {
        await api.post('/auth/logout');
        setUser(null);
        localStorage.removeItem('accessToken');
    };

    const refreshToken = useCallback(async () => {
        try {
            const { data } = await api.post('/auth/refresh');
            localStorage.setItem('accessToken', data.data.accessToken);
            return data.data.accessToken;
        } catch (error) {
            logout();
            throw error;
        }
    }, []);

    return (
        <AuthContext.Provider value={{ user, login, logout, refreshToken, loading }}>
            {children}
        </AuthContext.Provider>
    );
};

export const useAuth = () => useContext(AuthContext);
// auth.js - Vanilla JavaScript
class AuthService {
    constructor() {
        this.accessToken = localStorage.getItem('accessToken');
    }

    async login(email, password) {
        const response = await fetch('/api/auth/login', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ email, password }),
            credentials: 'include' // Important for cookies
        });
        
        const data = await response.json();
        
        if (data.success) {
            this.accessToken = data.data.accessToken;
            localStorage.setItem('accessToken', this.accessToken);
        }
        
        return data;
    }

    async fetchWithAuth(url, options = {}) {
        const headers = {
            ...options.headers,
            'Authorization': `Bearer ${this.accessToken}`
        };

        let response = await fetch(url, { ...options, headers });

        // If token expired, try to refresh
        if (response.status === 401) {
            const refreshed = await this.refreshToken();
            if (refreshed) {
                headers['Authorization'] = `Bearer ${this.accessToken}`;
                response = await fetch(url, { ...options, headers });
            }
        }

        return response;
    }

    async refreshToken() {
        try {
            const response = await fetch('/api/auth/refresh', {
                method: 'POST',
                credentials: 'include'
            });
            
            const data = await response.json();
            
            if (data.success) {
                this.accessToken = data.data.accessToken;
                localStorage.setItem('accessToken', this.accessToken);
                return true;
            }
            return false;
        } catch {
            this.logout();
            return false;
        }
    }

    logout() {
        this.accessToken = null;
        localStorage.removeItem('accessToken');
        window.location.href = '/login';
    }
}

export default new AuthService();
// utils/api.js - Axios with interceptors
import axios from 'axios';

const api = axios.create({
    baseURL: '/api',
    withCredentials: true // Send cookies
});

// Request interceptor - add access token
api.interceptors.request.use(
    (config) => {
        const token = localStorage.getItem('accessToken');
        if (token) {
            config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
    },
    (error) => Promise.reject(error)
);

// Response interceptor - handle token refresh
api.interceptors.response.use(
    (response) => response,
    async (error) => {
        const originalRequest = error.config;

        // If 401 and not already retrying
        if (error.response?.status === 401 && !originalRequest._retry) {
            originalRequest._retry = true;

            try {
                // Attempt to refresh token
                const { data } = await axios.post('/api/auth/refresh', {}, {
                    withCredentials: true
                });

                const newToken = data.data.accessToken;
                localStorage.setItem('accessToken', newToken);

                // Retry original request with new token
                originalRequest.headers.Authorization = `Bearer ${newToken}`;
                return api(originalRequest);

            } catch (refreshError) {
                // Refresh failed - redirect to login
                localStorage.removeItem('accessToken');
                window.location.href = '/login';
                return Promise.reject(refreshError);
            }
        }

        return Promise.reject(error);
    }
);

export default api;
About localStorage in these examples

The snippets keep the access token in localStorage because it's the simplest thing to show. Any XSS bug on your site can read it, though. For a real app I'd keep the access token in a JavaScript variable (memory) and rely on the httpOnly refresh cookie to get a new one after a page reload. Also note that with two requests failing at once, the Axios interceptor will fire two refresh calls; if you rotate refresh tokens, queue concurrent requests behind a single refresh promise.

Security Best Practices

Most JWT security problems aren't in the library. They come from how the tokens are configured and stored:

DO

  • Use strong, random secrets (256+ bits)
  • Set short expiration for access tokens (15 min)
  • Store refresh tokens in httpOnly cookies
  • Use HTTPS in production
  • Validate all claims on the server
  • Implement token rotation
  • Use RS256 for distributed systems

DON'T

  • Store tokens in localStorage (XSS vulnerable)
  • Include sensitive data in payload
  • Use weak or predictable secrets
  • Skip signature verification
  • Use long expiration for access tokens
  • Forget to validate 'iss' and 'aud' claims
  • Trust the token without verification
Common Vulnerability: Algorithm Confusion

Don't let the token's own header decide which algorithm to verify with. If your server expects RS256, an attacker can switch the header to HS256 and sign the token using your public key as the HMAC secret, which a naive verifier will accept. Pin the allowed algorithms:

jwt.verify(token, secret, { algorithms: ['HS256'] }); // βœ“ Safe

JWT vs Session-Based Auth

JWTs aren't automatically better than sessions. Here's how they compare:

Aspect JWT (Stateless) Sessions (Stateful)
Storage Client-side (token) Server-side (session store)
Scalability Excellent (no shared state) Requires sticky sessions or shared store
Revocation Difficult (need blacklist) Easy (delete from store)
Size Larger (contains claims) Small (just session ID)
Mobile/API Excellent Good (with cookie support)
Microservices Excellent Complex (shared session store)
Best For APIs, SPAs, Mobile, Microservices Traditional web apps, Simple setups

Token Revocation Strategies

Here's the awkward part. A signed token stays valid until it expires, so "log this user out now" or "this account was just banned" needs extra work. Three common options:

1. Short Expiration + Refresh Tokens

The most common approach. Keep access tokens short-lived (15 min) so any compromised token has limited validity.

// Access token: 15 minutes
// Refresh token: 7 days (stored in DB for revocation)

// To "logout" everywhere:
// 1. Delete all refresh tokens for user from DB
// 2. User must re-authenticate after access token expires

2. Token Blacklist / Deny List

Store revoked token IDs (jti claim) in a fast cache like Redis. Check on every request.

// In middleware
const isRevoked = await redis.get(`revoked:${decoded.jti}`);
if (isRevoked) {
    throw new Error('Token has been revoked');
}

// When revoking
await redis.setex(`revoked:${tokenId}`, TOKEN_TTL, '1');

⚠️ Adds state, but necessary for instant revocation.

3. Token Versioning

Include a version number in the token and user record. Increment when logging out everywhere.

// In token payload
{ userId: "123", tokenVersion: 5 }

// In database
{ userId: "123", tokenVersion: 5 }

// Validation
if (decoded.tokenVersion !== user.tokenVersion) {
    throw new Error('Token version mismatch - please re-authenticate');
}

Testing Your Implementation

You can exercise every endpoint with cURL. The -c and -b flags save and send the refresh cookie:

bash
# Register a new user
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123","name":"Test User"}'

# Login
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123"}' \
  -c cookies.txt

# Access protected route
curl http://localhost:3000/api/protected \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

# Refresh token (using cookie)
curl -X POST http://localhost:3000/api/auth/refresh \
  -b cookies.txt

# Get current user
curl http://localhost:3000/api/auth/me \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

# Logout
curl -X POST http://localhost:3000/api/auth/logout \
  -b cookies.txt -c cookies.txt

Production Checklist

Go through this list before the auth code ships:

Pre-Production Checklist

  • ☐ Strong, unique JWT secrets (256+ bits)
  • ☐ Secrets stored in environment variables
  • ☐ HTTPS enabled and enforced
  • ☐ Secure cookie settings (httpOnly, secure, sameSite)
  • ☐ Short access token expiration (≀15 min)
  • ☐ Refresh token rotation implemented
  • ☐ Rate limiting on auth endpoints
  • ☐ Password hashing (bcrypt, cost factor 12+)
  • ☐ Input validation and sanitization
  • ☐ Error messages don't leak information
  • ☐ Logging for security events
  • ☐ Token revocation strategy in place

When I'd use JWT (and when I wouldn't)

I'd pick JWTs when the API is consumed by mobile apps or third parties, when several services need to verify the same user without sharing a session store, or when you're already using an identity provider that issues them (Auth0, Cognito, Keycloak and friends).

I wouldn't use them for a classic server-rendered app on one domain, like a typical Laravel or Express site with Blade or EJS views. Sessions with a Redis store are simpler there, logout actually logs people out, and the cookie handling is already done for you. If you end up adding a deny list to every request anyway, you've rebuilt sessions with extra steps.

The thing to settle before writing any code is revocation: how fast do you need a stolen or banned user's access to stop? If "within 15 minutes" is acceptable, short access tokens plus revocable refresh tokens are enough. If it has to be instant, budget for a server-side check on every request, and weigh that against plain sessions.

JWT Authentication Security Node.js Express API Security
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications and secure authentication systems.