How to Set Up JWT Authentication in Node.js (Complete Guide)

A step-by-step guide to implementing JWT authentication in Node.js with Express. Covers access tokens, refresh tokens, middleware, password hashing, and common security mistakes to avoid.

Authentication is the first thing you build and the last thing you want to debug in production. JSON Web Tokens (JWT) have become the standard for stateless authentication in Node.js APIs. This guide walks you through a complete, production-ready implementation.

What is JWT and Why Use It?

JWT is a compact, self-contained token format that encodes user identity and claims as a signed JSON object. Unlike session-based auth (where the server stores session data), JWT is stateless - the token itself contains everything the server needs to verify a user.

This makes JWT ideal for:

  • REST APIs consumed by mobile apps and SPAs
  • Microservice architectures where services need to verify tokens independently
  • Applications that need to scale horizontally without shared session storage

Project Setup

Start with a clean Express project:

mkdir jwt-auth-demo
cd jwt-auth-demo
npm init -y
npm install express jsonwebtoken bcryptjs dotenv cookie-parser
npm install --save-dev nodemon

Create a .env file with your secrets:

ACCESS_TOKEN_SECRET=your-access-secret-here-min-32-chars
REFRESH_TOKEN_SECRET=your-refresh-secret-here-min-32-chars
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
PORT=3000

Important: Never hardcode secrets in your source code. Use environment variables and keep .env out of version control.

Step 1: User Registration with Password Hashing

Never store passwords as plain text. Use bcrypt to hash them:

const bcrypt = require("bcryptjs");

async function registerUser(email, password) {
    // Hash password with salt rounds of 12
    const hashedPassword = await bcrypt.hash(password, 12);

    // Save user to database
    const user = await db.users.create({
        email,
        password: hashedPassword
    });

    return user;
}

The salt rounds parameter (12) determines hashing cost. Higher is more secure but slower. 12 is the recommended minimum for production in 2026.

Step 2: Login and Token Generation

When a user logs in, verify their password and issue two tokens:

const jwt = require("jsonwebtoken");

async function loginUser(email, password) {
    const user = await db.users.findByEmail(email);
    if (!user) throw new Error("Invalid credentials");

    const isValid = await bcrypt.compare(password, user.password);
    if (!isValid) throw new Error("Invalid credentials");

    // Generate access token (short-lived)
    const accessToken = jwt.sign(
        { userId: user.id, email: user.email, role: user.role },
        process.env.ACCESS_TOKEN_SECRET,
        { expiresIn: process.env.ACCESS_TOKEN_EXPIRY }
    );

    // Generate refresh token (long-lived)
    const refreshToken = jwt.sign(
        { userId: user.id },
        process.env.REFRESH_TOKEN_SECRET,
        { expiresIn: process.env.REFRESH_TOKEN_EXPIRY }
    );

    return { accessToken, refreshToken };
}

Why two tokens? The access token is short-lived (15 minutes) so if it is stolen, the damage window is small. The refresh token is long-lived (7 days) and is used only to get new access tokens.

Step 3: Authentication Middleware

Create middleware that protects routes by verifying the access token:

function authenticateToken(req, res, next) {
    const authHeader = req.headers["authorization"];
    const token = authHeader && authHeader.split(" ")[1];

    if (!token) {
        return res.status(401).json({ error: "Access token required" });
    }

    jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, decoded) => {
        if (err) {
            return res.status(403).json({ error: "Invalid or expired token" });
        }
        req.user = decoded;
        next();
    });
}

// Use it on protected routes
app.get("/api/profile", authenticateToken, (req, res) => {
    res.json({ userId: req.user.userId, email: req.user.email });
});

Step 4: Token Refresh Flow

When the access token expires, the client uses the refresh token to get a new one without asking the user to log in again:

app.post("/api/refresh", (req, res) => {
    const refreshToken = req.cookies.refreshToken || req.body.refreshToken;

    if (!refreshToken) {
        return res.status(401).json({ error: "Refresh token required" });
    }

    jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, decoded) => {
        if (err) {
            return res.status(403).json({ error: "Invalid refresh token" });
        }

        // Issue new access token
        const accessToken = jwt.sign(
            { userId: decoded.userId, email: decoded.email, role: decoded.role },
            process.env.ACCESS_TOKEN_SECRET,
            { expiresIn: process.env.ACCESS_TOKEN_EXPIRY }
        );

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

Step 5: Role-Based Access Control (RBAC)

Add authorization on top of authentication:

function authorizeRoles(...allowedRoles) {
    return (req, res, next) => {
        if (!allowedRoles.includes(req.user.role)) {
            return res.status(403).json({ error: "Insufficient permissions" });
        }
        next();
    };
}

// Only admins can delete users
app.delete("/api/users/:id",
    authenticateToken,
    authorizeRoles("admin"),
    deleteUserHandler
);

// Admins and managers can view reports
app.get("/api/reports",
    authenticateToken,
    authorizeRoles("admin", "manager"),
    getReportsHandler
);

Step 6: Secure Token Storage

Where you store tokens matters for security:

  • Access token: Store in memory (JavaScript variable). Never in localStorage - it is vulnerable to XSS attacks.
  • Refresh token: Store in an HTTP-only, secure, same-site cookie. This prevents JavaScript from accessing it.
// Set refresh token as HTTP-only cookie
res.cookie("refreshToken", refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "strict",
    maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});

Common Security Mistakes to Avoid

1. Storing Tokens in localStorage

Any XSS vulnerability in your app can steal tokens from localStorage. Use HTTP-only cookies for refresh tokens and in-memory storage for access tokens.

2. Not Validating Token Expiry

Always set and check expiry times. A token without an expiry is valid forever - if leaked, it gives permanent access.

3. Using Weak Secrets

Your JWT secret should be at least 256 bits (32 characters) of random data. Do not use words, phrases, or anything guessable. Generate secrets with openssl rand -hex 32.

4. Putting Sensitive Data in the Payload

JWT payloads are Base64-encoded, not encrypted. Anyone can decode and read them. Never put passwords, credit card numbers, or sensitive personal data in a JWT.

5. Not Implementing Token Revocation

JWT is stateless, so you cannot invalidate a specific token. Implement a token blacklist (using Redis) for logout and password change flows, or use short expiry times and refresh tokens.

Production Checklist

  • Use HTTPS in production (tokens are sent in headers)
  • Set appropriate CORS headers
  • Rate-limit login and refresh endpoints
  • Log authentication failures for monitoring
  • Rotate secrets periodically
  • Add request validation with a library like Joi or Zod
  • Store refresh tokens in the database so you can revoke them per-user

Summary

JWT authentication in Node.js is straightforward once you understand the pieces: hash passwords with bcrypt, generate short-lived access tokens and long-lived refresh tokens, verify tokens in middleware, and store them securely. The most common production issues come from insecure token storage and missing token expiry - get those right, and you have a solid authentication system.

Share This Article

Tags

Node.js JWT Authentication Express Security REST API Backend
Vivek Tyagi
About the Author
Vivek Tyagi
Senior Web Developer

Vivek is a backend-focused engineer with 4+ years of experience designing robust APIs, database architectures, and server-side systems that power complex web applications. He brings deep expertise in PHP, Laravel, CodeIgniter, MySQL, PostgreSQL, and RESTful API design, along with extensive experience integrating third-party services including Stripe, PayPal, QuickBooks Online, UPS, USPS, and webhook-driven automation workflows. At Logic Providers, Vivek has architected backend systems for multi-tenant SaaS platforms, high-volume e-commerce sites, and data-intensive business applications. He excels at writing clean, maintainable code with comprehensive test coverage, and has a strong background in database optimization, caching strategies, payment gateway integration, and security best practices including JWT authentication and role-based access control.

Connect on LinkedIn
How to Set Up JWT Authentication in Node.js (Complete Guide)
Written by
Vivek Tyagi
Vivek Tyagi
LinkedIn
Published
April 17, 2026
Read Time
9 min read
Category
Development
Tags
Node.js JWT Authentication Express Security REST API Backend
Start Your Project

Related Articles

JWT vs OAuth2 - Which One Should You Actually Use?
Development March 5, 2026

JWT vs OAuth2 - Which One Should You Actually Use?

Most teams pick JWT or OAuth2 because they've heard of it, not because they actually thought through the tradeoff. This post cuts through the confusion with concrete examples, production lessons, and a practical decision framework for PHP developers.

Read More

Have a Project in Mind?

Let's discuss how we can help bring your vision to life.