Skip to main content

Command Palette

Search for a command to run...

JWT Authentication in Node.js: A Complete Beginner's Guide

Published
14 min read
JWT Authentication in Node.js: A Complete Beginner's Guide

Learn JWT Authentication in Node.js with simple examples. Build a mini auth system with register, login, and protected routes explained step by step.

Introduction: Why Authentication Matters

Imagine you're building a house. You wouldn't leave your front door wide open for anyone to walk in, right? The same principle applies to web applications. Authentication is your digital door lock – it ensures only the right people can access specific parts of your app.

In web development, authentication is the process of verifying who a user is. Whether it's a social media platform, banking app, or even a simple blog, most applications need to:

  • Identify users (who are you?)

  • Control access to resources (what can you see/do?)

  • Maintain user sessions (stay logged in)

There are several ways to handle authentication:

  • Session-based authentication (traditional approach using server-side sessions)

  • Cookie-based authentication (storing user data in browser cookies)

  • JWT Authentication (our focus today - using JSON Web Tokens)

Today, we'll dive deep into JWT authentication in Node.js and build a complete authentication system from scratch.

What is JWT? (Think of it as a Digital Passport)

JWT (JSON Web Token) is like a digital passport for your web application. Just as a passport contains your personal information and proves your identity when traveling, a JWT contains user information and proves their identity when accessing your app.

Here's the beautiful thing about JWTs: they're self-contained. Unlike traditional sessions where the server needs to store user data, JWTs carry all the necessary information within themselves.

JWT Structure: Three Parts, One Token

A JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

It consists of three parts separated by dots:

  1. Header - Contains metadata about the token

  2. Payload - Contains the actual user data

  3. Signature - Ensures the token hasn't been tampered with

Think of it like a tamper-proof envelope:

  • The header tells you what type of envelope it is

  • The payload contains the letter inside

  • The signature is the wax seal that proves no one opened it

JWT vs Sessions: Quick Comparison

JWTSessions
Stateless (server doesn't store anything)Stateful (server stores session data)
Great for microservices & APIsTraditional approach
Token stored on client sideSession ID stored on client, data on server
Self-containedRequires server lookup

Building JWT Authentication: Step by Step

Let's build a complete authentication system! We'll create a Node.js app with user registration, login, and protected routes.

Step 1: Project Setup

First, let's set up our project:

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

Create the following file structure:

jwt-auth-tutorial/
├── server.js
├── .env
└── package.json

Step 2: Environment Variables (.env file)

PORT=5000
JWT_SECRET=your_super_secret_jwt_key_here_make_it_long_and_random

Important: In production, use a strong, random secret key. Never commit this to version control!

Step 3: Basic Server Setup (server.js)

const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
require("dotenv").config();

const app = express();

// Middleware to parse JSON
app.use(express.json());

// In-memory user storage (use a database in production!)
let users = [];

// Helper function to find user by email
const findUserByEmail = (email) => {
  return users.find((user) => user.email === email);
};

// Helper function to find user by ID
const findUserById = (id) => {
  return users.find((user) => user.id === id);
};

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

Step 4: User Registration

// USER REGISTRATION ENDPOINT
app.post("/api/register", async (req, res) => {
  try {
    const { name, email, password } = req.body;

    // Basic validation
    if (!name || !email || !password) {
      return res.status(400).json({
        message: "Please provide name, email, and password",
      });
    }

    // Check if user already exists
    if (findUserByEmail(email)) {
      return res.status(400).json({
        message: "User already exists with this email",
      });
    }

    // Hash the password (salt rounds = 10)
    const hashedPassword = await bcrypt.hash(password, 10);

    // Create new user
    const newUser = {
      id: users.length + 1, // Simple ID generation
      name,
      email,
      password: hashedPassword,
    };

    // Save user to our "database"
    users.push(newUser);

    res.status(201).json({
      message: "User registered successfully",
      user: {
        id: newUser.id,
        name: newUser.name,
        email: newUser.email,
        // Never send password back!
      },
    });
  } catch (error) {
    console.error("Registration error:", error);
    res.status(500).json({ message: "Server error" });
  }
});

Step 5: User Login (JWT Generation)

// USER LOGIN ENDPOINT
app.post("/api/login", async (req, res) => {
  try {
    const { email, password } = req.body;

    // Basic validation
    if (!email || !password) {
      return res.status(400).json({
        message: "Please provide email and password",
      });
    }

    // Find user by email
    const user = findUserByEmail(email);
    if (!user) {
      return res.status(400).json({
        message: "Invalid credentials",
      });
    }

    // Check password
    const isPasswordValid = await bcrypt.compare(password, user.password);
    if (!isPasswordValid) {
      return res.status(400).json({
        message: "Invalid credentials",
      });
    }

    // Create JWT payload
    const payload = {
      user: {
        id: user.id,
        email: user.email,
      },
    };

    // Generate JWT token
    const token = jwt.sign(
      payload,
      process.env.JWT_SECRET,
      { expiresIn: "1h" } // Token expires in 1 hour
    );

    res.json({
      message: "Login successful",
      token,
      user: {
        id: user.id,
        name: user.name,
        email: user.email,
      },
    });
  } catch (error) {
    console.error("Login error:", error);
    res.status(500).json({ message: "Server error" });
  }
});

Step 6: JWT Verification Middleware

This is where the magic happens! This middleware will run before any protected route to verify the JWT:

// JWT VERIFICATION MIDDLEWARE
const authenticateToken = (req, res, next) => {
  // Get token from header
  const authHeader = req.headers["authorization"];
  const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN

  // Check if token exists
  if (!token) {
    return res.status(401).json({
      message: "Access token required",
    });
  }

  // Verify token
  jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
    if (err) {
      return res.status(403).json({
        message: "Invalid or expired token",
      });
    }

    // Token is valid, save user info to request object
    req.user = decoded.user;
    next(); // Continue to the next middleware/route
  });
};

Step 7: Protected Routes

Now let's create some protected routes that require authentication:

// PROTECTED ROUTE - Get user profile
app.get("/api/profile", authenticateToken, (req, res) => {
  // req.user is available because of our middleware
  const user = findUserById(req.user.id);

  if (!user) {
    return res.status(404).json({ message: "User not found" });
  }

  res.json({
    message: "Profile data retrieved successfully",
    user: {
      id: user.id,
      name: user.name,
      email: user.email,
    },
  });
});

// PROTECTED ROUTE - Get all users (admin-like functionality)
app.get("/api/users", authenticateToken, (req, res) => {
  // Return all users without passwords
  const safeUsers = users.map((user) => ({
    id: user.id,
    name: user.name,
    email: user.email,
  }));

  res.json({
    message: "Users retrieved successfully",
    users: safeUsers,
  });
});

// PUBLIC ROUTE - Anyone can access
app.get("/api/public", (req, res) => {
  res.json({
    message: "This is a public endpoint. No authentication required!",
  });
});

Complete Server Code

Here's the complete server.js file:

const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
require("dotenv").config();

const app = express();

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

// In-memory user storage (use a database in production!)
let users = [];

// Helper functions
const findUserByEmail = (email) => users.find((user) => user.email === email);
const findUserById = (id) => users.find((user) => user.id === id);

// JWT Verification Middleware
const authenticateToken = (req, res, next) => {
  const authHeader = req.headers["authorization"];
  const token = authHeader && authHeader.split(" ")[1];

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

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

// Routes
app.post("/api/register", async (req, res) => {
  try {
    const { name, email, password } = req.body;

    if (!name || !email || !password) {
      return res
        .status(400)
        .json({ message: "Please provide name, email, and password" });
    }

    if (findUserByEmail(email)) {
      return res
        .status(400)
        .json({ message: "User already exists with this email" });
    }

    const hashedPassword = await bcrypt.hash(password, 10);
    const newUser = {
      id: users.length + 1,
      name,
      email,
      password: hashedPassword,
    };

    users.push(newUser);

    res.status(201).json({
      message: "User registered successfully",
      user: { id: newUser.id, name: newUser.name, email: newUser.email },
    });
  } catch (error) {
    console.error("Registration error:", error);
    res.status(500).json({ message: "Server error" });
  }
});

app.post("/api/login", async (req, res) => {
  try {
    const { email, password } = req.body;

    if (!email || !password) {
      return res
        .status(400)
        .json({ message: "Please provide email and password" });
    }

    const user = findUserByEmail(email);
    if (!user) {
      return res.status(400).json({ message: "Invalid credentials" });
    }

    const isPasswordValid = await bcrypt.compare(password, user.password);
    if (!isPasswordValid) {
      return res.status(400).json({ message: "Invalid credentials" });
    }

    const payload = {
      user: {
        id: user.id,
        email: user.email,
      },
    };

    const token = jwt.sign(payload, process.env.JWT_SECRET, {
      expiresIn: "1h",
    });

    res.json({
      message: "Login successful",
      token,
      user: { id: user.id, name: user.name, email: user.email },
    });
  } catch (error) {
    console.error("Login error:", error);
    res.status(500).json({ message: "Server error" });
  }
});

app.get("/api/profile", authenticateToken, (req, res) => {
  const user = findUserById(req.user.id);
  if (!user) {
    return res.status(404).json({ message: "User not found" });
  }

  res.json({
    message: "Profile data retrieved successfully",
    user: { id: user.id, name: user.name, email: user.email },
  });
});

app.get("/api/users", authenticateToken, (req, res) => {
  const safeUsers = users.map((user) => ({
    id: user.id,
    name: user.name,
    email: user.email,
  }));

  res.json({
    message: "Users retrieved successfully",
    users: safeUsers,
  });
});

app.get("/api/public", (req, res) => {
  res.json({
    message: "This is a public endpoint. No authentication required!",
  });
});

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

Testing Your Authentication System

Let's test our JWT authentication system using a tool like Postman or curl:

1. Register a User

POST http://localhost:5000/api/register
Content-Type: application/json

{
    "name": "John Doe",
    "email": "john@example.com",
    "password": "mypassword123"
}

2. Login

POST http://localhost:5000/api/login
Content-Type: application/json

{
    "email": "john@example.com",
    "password": "mypassword123"
}

This will return a token. Copy it!

3. Access Protected Route

GET http://localhost:5000/api/profile
Authorization: Bearer YOUR_JWT_TOKEN_HERE

JWT Authentication Flow Diagram

┌─────────────┐    ┌──────────────┐    ┌─────────────┐
│   Client    │    │    Server    │    │  Database   │
│  (Browser)  │    │              │    │             │
└─────────────┘    └──────────────┘    └─────────────┘
       │                   │                   │
       │  1. POST /login   │                   │
       │  (email/password) │                   │
       ├──────────────────►│                   │
       │                   │ 2. Verify user    │
       │                   ├──────────────────►│
       │                   │                   │
       │                   │ 3. User data      │
       │                   │◄──────────────────┤
       │                   │                   │
       │                   │ 4. Generate JWT   │
       │                   │    (sign token)   │
       │                   │                   │
       │ 5. Return JWT     │                   │
       │◄──────────────────┤                   │
       │                   │                   │
       │ 6. Store JWT      │                   │
       │    (localStorage/ │                   │
       │     sessionStorage│                   │
       │     or cookie)    │                   │
       │                   │                   │
       │ 7. GET /profile   │                   │
       │ Header: Bearer JWT│                   │
       ├──────────────────►│                   │
       │                   │ 8. Verify JWT     │
       │                   │    (check signature│
       │                   │     & expiration) │
       │                   │                   │
       │ 9. Protected data │                   │
       │◄──────────────────┤                   │

Best Practices & Security Tips

1. JWT Secret Management

// ❌ DON'T DO THIS
const JWT_SECRET = "mysecret";

// ✅ DO THIS
const JWT_SECRET = process.env.JWT_SECRET; // Long, random string

2. Password Security

// ✅ Always hash passwords
const hashedPassword = await bcrypt.hash(password, 10);

// ✅ Use strong salt rounds (10-12 is good)
const saltRounds = 12;

3. Token Expiration

// ✅ Set reasonable expiration times
const token = jwt.sign(payload, JWT_SECRET, {
  expiresIn: "15m", // Short-lived access tokens
});

4. What NOT to Store in JWT

// ❌ DON'T store sensitive data
const payload = {
  userId: user.id,
  password: user.password, // NEVER!
  creditCard: user.cc, // NEVER!
};

// ✅ Store minimal, non-sensitive data
const payload = {
  user: {
    id: user.id,
    email: user.email,
    role: user.role,
  },
};

Bonus: Access Tokens vs Refresh Tokens

For production applications, you should implement a two-token system:

Access Token vs Refresh Token

Access TokenRefresh Token
Short-lived (5-15 minutes)Long-lived (7-30 days)
Used for API requestsUsed to get new access tokens
Stored in memory or short-term storageStored securely (httpOnly cookie)
Contains user permissionsContains minimal user info

Refresh Token Flow Diagram

┌─────────────┐    ┌──────────────┐
│   Client    │    │    Server    │
└─────────────┘    └──────────────┘
       │                   │
       │ 1. Login Request  │
       ├──────────────────►│
       │                   │
       │ 2. Access Token + │
       │    Refresh Token  │
       │◄──────────────────┤
       │                   │
       │ 3. API Requests   │
       │ (with Access Token)│
       ├──────────────────►│
       │                   │
       │ 4. Access Token   │
       │    Expires        │
       │                   │
       │ 5. Use Refresh    │
       │    Token to get   │
       │    new Access Token│
       ├──────────────────►│
       │                   │
       │ 6. New Access Token│
       │◄──────────────────┤
       │                   │
       │ 7. Continue API   │
       │    Requests       │
       ├──────────────────►│

Implementation Example

// Generate both tokens
const generateTokens = (user) => {
  const payload = { user: { id: user.id, email: user.email } };

  const accessToken = jwt.sign(payload, process.env.JWT_SECRET, {
    expiresIn: "15m",
  });

  const refreshToken = jwt.sign(payload, process.env.REFRESH_TOKEN_SECRET, {
    expiresIn: "7d",
  });

  return { accessToken, refreshToken };
};

// Refresh token endpoint
app.post("/api/refresh", (req, res) => {
  const { refreshToken } = req.body;

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

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

    const user = findUserById(decoded.user.id);
    const { accessToken } = generateTokens(user);

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

JWT vs Sessions: When to Use What?

Use JWT When:

  • Building APIs or microservices

  • Need stateless authentication

  • Working with mobile apps

  • Implementing single sign-on (SSO)

  • Scaling across multiple servers

Use Sessions When:

  • Building traditional web applications

  • Need server-side session management

  • Require immediate token invalidation

  • Working with server-side rendered apps

Common Mistakes to Avoid

1. Storing JWT in localStorage

// ❌ Vulnerable to XSS attacks
localStorage.setItem("token", jwt);

// ✅ Better: Use httpOnly cookies for web apps
res.cookie("token", jwt, {
  httpOnly: true,
  secure: true,
  sameSite: "strict",
});

2. Not Handling Token Expiration

// ❌ No expiration handling
fetch("/api/profile", {
  headers: { Authorization: `Bearer ${token}` },
});

// ✅ Handle expired tokens
fetch("/api/profile", {
  headers: { Authorization: `Bearer ${token}` },
}).then((response) => {
  if (response.status === 403) {
    // Token expired, redirect to login
    window.location.href = "/login";
  }
  return response.json();
});

3. Weak JWT Secrets

// ❌ Weak secret
const JWT_SECRET = "secret";

// ✅ Strong secret (at least 256 bits)
const JWT_SECRET =
  "your-super-long-and-random-secret-key-that-is-at-least-256-bits-long";

Performance Considerations

JWT Pros:

  • Stateless: No server-side storage needed

  • Scalable: Works great with load balancers

  • Fast: No database lookup for each request

  • Cross-domain: Perfect for microservices

JWT Cons:

  • Size: Larger than session IDs

  • Immutable: Can't revoke tokens easily

  • Storage: Client needs to store the token securely

Real-World Production Tips

1. Database Integration

Replace our in-memory users array with a real database:

// Example with MongoDB/Mongoose
const User = require("./models/User");

const findUserByEmail = async (email) => {
  return await User.findOne({ email });
};

const createUser = async (userData) => {
  const user = new User(userData);
  return await user.save();
};

2. Rate Limiting

Add rate limiting to prevent brute force attacks:

const rateLimit = require("express-rate-limit");

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 attempts per window
  message: "Too many login attempts, please try again later",
});

app.post("/api/login", loginLimiter, async (req, res) => {
  // login logic
});

3. CORS Configuration

const cors = require("cors");

app.use(
  cors({
    origin: process.env.FRONTEND_URL,
    credentials: true,
  })
);

Summary: Why Choose JWT?

JWT authentication offers several compelling benefits for modern web applications:

Security: JWTs are cryptographically signed, making them tamper-proof. The signature ensures that the token hasn't been modified in transit.

Stateless: Unlike traditional sessions, JWTs don't require server-side storage. This makes your application more scalable and reduces server memory usage.

Flexibility: JWTs work seamlessly across different domains and are perfect for microservices architecture. You can use the same token to authenticate across multiple services.

Performance: No database lookups needed for each request validation, making your API faster and more responsive.

Mobile-Friendly: JWTs work great with mobile apps and single-page applications where traditional session cookies might be problematic.

Next Steps

Now that you understand JWT authentication in Node.js, here are some areas to explore further:

  1. Database Integration: Replace in-memory storage with MongoDB, PostgreSQL, or your preferred database

  2. OAuth Integration: Learn how to implement social login (Google, GitHub, Facebook)

  3. Session-Based Authentication: Compare with traditional session-based approaches

  4. Advanced Security: Implement refresh tokens, token blacklisting, and rate limiting

  5. Frontend Integration: Connect your JWT backend with React, Vue, or Angular applications

  6. Testing: Write comprehensive tests for your authentication system

TL;DR - Quick Summary

JWT (JSON Web Token) is a secure, self-contained way to authenticate users in Node.js applications. Here's what we covered:

What JWT is: A digital passport containing user info that proves identity ✅ Key Benefits: Stateless, scalable, secure, and perfect for APIs ✅ Implementation: Built complete registration, login, and protected routes ✅ Security: Password hashing with bcrypt, JWT signing with secrets ✅ Best Practices: Token expiration, secure storage, minimal payload data ✅ Testing: How to test your authentication endpoints

Quick Start Checklist:

  • ✅ Install: express, bcryptjs, jsonwebtoken, dotenv

  • ✅ Set up environment variables (JWT_SECRET)

  • ✅ Create registration endpoint with password hashing

  • ✅ Create login endpoint that returns JWT

  • ✅ Create middleware to verify JWTs

  • ✅ Protect routes using the middleware

  • ✅ Test all endpoints

The complete code example shows a working authentication system that you can use as a foundation for your own projects. Remember to replace the in-memory storage with a real database and implement additional security measures for production use.

JWT authentication strikes an excellent balance between security, performance, and developer experience, making it a popular choice for modern web applications and APIs.


Found this helpful? Follow me for more Node.js tutorials and web development tips! 🚀