JWT
Security

Authentication with JWT: Complete Implementation Guide

Mayur Dabhi
Mayur Dabhi
February 21, 2026
20 min read

You've built a beautiful API, but now you face the critical challenge every developer encounters: How do you securely authenticate users? How do you ensure that only authorized users can access protected resources? How do you maintain sessions without the overhead of server-side session storage?

Enter JSON Web Tokens (JWT)β€”the industry-standard solution for stateless authentication that has revolutionized how we build secure APIs. In this comprehensive guide, we'll explore everything you need to know about JWT authentication, from understanding the fundamentals to implementing a production-ready solution.

Why JWT Matters

JWTs enable stateless authentication, meaning the server doesn't need to store session data. This makes your application more scalable, easier to deploy across multiple servers, and perfect for microservices architectures.

What is a JSON Web Token?

A JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. Think of it as a digitally signed passport that contains information about the user and can be verified without contacting the issuing authority.

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

Understanding the authentication flow is crucial for implementing JWT correctly. Here's how the process works from login to accessing protected resources:

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

Let's implement a complete JWT authentication system using Node.js and Express. We'll cover user registration, login, token generation, and protected routes.

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

Never commit your .env file to version control! Add it to .gitignore. In production, use environment variables provided by your hosting platform. Your JWT secret should be at least 256 bits (32 characters) of random data.

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 };

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

A robust JWT implementation uses two types of tokens for enhanced security. Understanding their differences is crucial:

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

Here's how to handle JWT authentication on the frontend, including automatic token refresh:

// 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;

Security Best Practices

JWT authentication is powerful but can be vulnerable if implemented incorrectly. Follow these security best practices:

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

Never accept the algorithm from the token itself without validation. An attacker could change RS256 to HS256 and sign with a public key. Always specify allowed algorithms explicitly:

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

JWT vs Session-Based Auth

Both approaches have their place. Here's a comparison to help you choose:

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

One challenge with JWTs is revocation. Since tokens are self-contained, the server can't invalidate them easily. Here are strategies to handle this:

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

Use these cURL commands to test your JWT authentication endpoints:

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

Before deploying your JWT authentication to production, verify these items:

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

Conclusion

JWT authentication provides a powerful, scalable solution for modern applications. By understanding how tokens work, implementing proper security measures, and following best practices, you can build authentication systems that are both user-friendly and secure.

Key takeaways:

With this guide, you have everything you need to implement robust JWT authentication in your Node.js applications. Happy coding! πŸ”

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.