DEV Community

Cover image for JWT Authentication in Node.js Explained Simply
Pratham
Pratham

Posted on

JWT Authentication in Node.js Explained Simply

How to build a login system where the server doesn't need to remember anything about you.


Here's the problem with building any application that has user accounts: how does the server know that the person making a request is actually who they say they are?

You log in once. But then you visit 10 different pages, make 15 API calls, upload a file. Each of those is a separate HTTP request, and HTTP is stateless — every request is completely independent. The server has no memory of the previous one. So how does it know you're still logged in?

The answer is authentication tokens — and the most popular one is JWT (JSON Web Token). You log in, the server gives you a token, and you show that token with every request. No session storage. No database lookup. The token itself proves who you are.

Let me show you how this works from scratch in Node.js. This was one of the most practical things I've built in the ChaiCode Web Dev Cohort 2026.


What Does Authentication Mean?

Authentication answers one question: "Are you who you claim to be?"

Think of it like checking into a hotel:

  1. You arrive and show your ID at the front desk → the hotel verifies your identity
  2. The hotel gives you a room key card → proof that you've been verified
  3. Every time you enter your room, you swipe the key card → the door trusts the card without calling the front desk again

In web terms:

Step 1: User sends credentials (email + password) → Login
Step 2: Server verifies and sends back a token    → "Here's your key card"
Step 3: User sends token with every request        → "Here's my key card"
Step 4: Server checks the token                    → "Valid card, come in"
Enter fullscreen mode Exit fullscreen mode

The token is the key card. The server is the door lock. And JWT is the specific type of key card we're using.


What Is JWT?

JWT (JSON Web Token) is a compact, self-contained token that carries information about a user. The key word is self-contained — the token itself holds the user data. The server doesn't need to store anything or look anything up.

Why "Self-Contained"?

With a traditional session, the server gives you a random ID and stores your data:

Session approach:
  Token: "abc123"  (meaningless — server has to look it up)
  Server: "Let me check my database... abc123 = Pratham, developer"

JWT approach:
  Token: "eyJhbGci..." (contains user data inside it!)
  Server: "Let me verify the signature... valid!
           The token says id=1, name=Pratham, role=developer"
Enter fullscreen mode Exit fullscreen mode

The server creates the JWT, sends it to the client, and forgets about it. When the client sends it back, the server can verify it using only the secret key — no storage, no database, no session state.


Structure of a JWT

Every JWT is a single string made up of three parts separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwibmFtZSI6IlByYXRoYW0iLCJyb2xlIjoiZGV2ZWxvcGVyIiwiaWF0IjoxNzE1MzQ4MDAwLCJleHAiOjE3MTU0MzQ0MDB9.kX9_7TgL2Rm5pVa0Q1sWnJkGYM7v3BcFhR9Xp2yNmW4
└─────────── Header ───────────┘└──────────────────────────── Payload ────────────────────────────┘└───────── Signature ─────────┘
Enter fullscreen mode Exit fullscreen mode

Part 1: Header

The header tells the server how the token was signed.

{
  "alg": "HS256",
  "typ": "JWT"
}
Enter fullscreen mode Exit fullscreen mode
  • alg — the algorithm used to create the signature (HS256 is common)
  • typ — the token type (always "JWT")

This JSON is Base64-encoded (not encrypted — anyone can decode it).

Part 2: Payload

The payload contains the actual data — called claims. This is where user information lives.

{
  "id": 1,
  "name": "Pratham",
  "role": "developer",
  "iat": 1715348000,
  "exp": 1715434400
}
Enter fullscreen mode Exit fullscreen mode
  • id, name, role — your custom data (whatever you put in)
  • iat — "issued at" timestamp (when the token was created)
  • exp — "expires at" timestamp (when the token becomes invalid)

This is also Base64-encoded — readable by anyone. Never put passwords or sensitive data in a JWT payload.

Part 3: Signature

The signature is what makes JWT secure. It's created by combining the header, payload, and a secret key that only the server knows.

Signature = HMAC-SHA256(
  base64(header) + "." + base64(payload),
  "my-secret-key"
)
Enter fullscreen mode Exit fullscreen mode

The signature ensures two things:

  1. The token hasn't been tampered with — if anyone changes the payload, the signature won't match
  2. The token was created by your server — only your server knows the secret key
JWT Security Model:

  ┌────────┐  ┌─────────┐  ┌───────────┐
  │ Header │  │ Payload │  │ Signature │
  │        │  │         │  │           │
  │ Anyone │  │ Anyone  │  │ Only the  │
  │ can    │  │ can     │  │ server    │
  │ READ   │  │ READ    │  │ can       │
  │ this   │  │ this    │  │ CREATE or │
  │        │  │         │  │ VERIFY    │
  │        │  │         │  │ this      │
  └────────┘  └─────────┘  └───────────┘

  The data is PUBLIC. The verification is PRIVATE.
Enter fullscreen mode Exit fullscreen mode

Login Flow Using JWT

Here's the complete authentication flow from login to protected access:

JWT Login Authentication Flow

┌──────────┐                                ┌──────────────┐
  Client                                      Server    
└────┬─────┘                                └──────┬───────┘
                                                  
       1. POST /login                             
          { email: "pratham@dev.in",              
            password: "mypassword" }              
      ──────────────────────────────────────→      
                                                  
                                         2. Find user in DB
                                         3. Compare password hash
                                         4. Password matches? 
                                         5. Create JWT:
                                            jwt.sign({
                                              id: 1,
                                              name: "Pratham",
                                              role: "developer"
                                            }, SECRET, { expiresIn: "7d" })
                                                  
       6. Response:                               
          { token: "eyJhbGci..." }                
      ←──────────────────────────────────────      
                                                  
       7. Client stores the token                 
          (localStorage, cookie, or memory)       
                                                  
       8. GET /api/profile                        
          Headers:                                
            Authorization: Bearer eyJhbGci...     
      ──────────────────────────────────────→      
                                                  
                                         9. Extract token from header
                                        10. jwt.verify(token, SECRET)
                                        11. Valid?   extract user
                                                  
       12. Response:                              
           { user: { id: 1, name: "Pratham" } }   
      ←──────────────────────────────────────      
                                                  
└────┴─────┘                                └──────┴───────┘
Enter fullscreen mode Exit fullscreen mode

Step-by-Step Implementation

1. Project Setup

mkdir jwt-auth-demo
cd jwt-auth-demo
npm init -y
npm install express jsonwebtoken bcryptjs
Enter fullscreen mode Exit fullscreen mode
  • express — web framework
  • jsonwebtoken — create and verify JWTs
  • bcryptjs — hash passwords securely

2. Complete Auth Server

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

const app = express();
app.use(express.json());

// Secret key — in production, use environment variables!
const JWT_SECRET = "my-super-secret-key-change-this-in-production";

// Simulated database
const users = [];

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

  // Validation
  if (!name || !email || !password) {
    return res.status(400).json({ error: "All fields are required" });
  }

  // Check if user exists
  if (users.find((u) => u.email === email)) {
    return res.status(400).json({ error: "Email already registered" });
  }

  // Hash the password (NEVER store plain text passwords)
  const hashedPassword = await bcrypt.hash(password, 10);

  // Save user
  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 },
  });
});

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

  // Find user
  const user = users.find((u) => u.email === email);
  if (!user) {
    return res.status(401).json({ error: "Invalid email or password" });
  }

  // Compare password with hash
  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) {
    return res.status(401).json({ error: "Invalid email or password" });
  }

  // Create JWT
  const token = jwt.sign(
    { id: user.id, name: user.name, email: user.email },
    JWT_SECRET,
    { expiresIn: "7d" },
  );

  res.json({
    message: "Login successful!",
    token,
  });
});

app.listen(3000, () => {
  console.log("Auth server running on http://localhost:3000");
});
Enter fullscreen mode Exit fullscreen mode

Sending Token with Requests

Once the client has a token, it sends it with every request that needs authentication. The standard way is via the Authorization header:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Enter fullscreen mode Exit fullscreen mode

From a Frontend (React, Vanilla JS)

// After login, store the token
const loginResponse = await fetch("/login", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email: "pratham@dev.in", password: "mypassword" }),
});
const { token } = await loginResponse.json();

// Use the token for protected requests
const profileResponse = await fetch("/api/profile", {
  headers: {
    Authorization: `Bearer ${token}`,
  },
});
const profile = await profileResponse.json();
console.log(profile); // { user: { id: 1, name: "Pratham", ... } }
Enter fullscreen mode Exit fullscreen mode

From curl (Terminal)

# Login and get token
curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email": "pratham@dev.in", "password": "mypassword"}'

# Use token for protected route
curl http://localhost:3000/api/profile \
  -H "Authorization: Bearer eyJhbGci..."
Enter fullscreen mode Exit fullscreen mode

Where to Store the Token on the Client

Storage Pros Cons
localStorage Easy to use, persists across tabs Vulnerable to XSS attacks
httpOnly cookie Can't be accessed by JS (XSS-safe) Vulnerable to CSRF (mitigated with sameSite)
Memory (variable) Most secure, gone on refresh Lost when user refreshes the page

For learning: localStorage is fine. For production: httpOnly cookies are recommended.


Protecting Routes Using Tokens

This is where it all comes together. You create a middleware that checks for a valid JWT before allowing access to protected routes.

Auth Middleware

function authenticate(req, res, next) {
  // 1. Get the Authorization header
  const authHeader = req.headers.authorization;

  // 2. Check if it exists and starts with "Bearer "
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Access denied. No token provided." });
  }

  // 3. Extract the token (remove "Bearer ")
  const token = authHeader.split(" ")[1];

  // 4. Verify the token
  try {
    const decoded = jwt.verify(token, JWT_SECRET);
    req.user = decoded; // Attach user data to the request
    next(); // Token valid — proceed to route handler
  } catch (error) {
    if (error.name === "TokenExpiredError") {
      return res.status(401).json({ error: "Token expired. Please log in again." });
    }
    return res.status(403).json({ error: "Invalid token." });
  }
}
Enter fullscreen mode Exit fullscreen mode

Token Validation Lifecycle

Request arrives with: Authorization: Bearer eyJhbGci...
        │
        ↓
┌──────────────────────────────────────────────┐
│           AUTH MIDDLEWARE                      │
│                                              │
│  1. Header present?                          │
│     NO  → 401 "No token provided"           │
│     YES ↓                                    │
│                                              │
│  2. Starts with "Bearer "?                   │
│     NO  → 401 "No token provided"           │
│     YES ↓                                    │
│                                              │
│  3. Extract token after "Bearer "            │
│                                              │
│  4. jwt.verify(token, SECRET)                │
│     EXPIRED  → 401 "Token expired"          │
│     INVALID  → 403 "Invalid token"          │
│     VALID    ↓                               │
│                                              │
│  5. Attach decoded payload to req.user       │
│                                              │
│  6. Call next() → proceed to route handler   │
│                                              │
└──────────────────────────────────────────────┘
        │
        ↓
  Route handler runs with req.user available
Enter fullscreen mode Exit fullscreen mode

Using the Middleware

// Public routes — anyone can access
app.post("/register", (req, res) => { /* ... */ });
app.post("/login", (req, res) => { /* ... */ });

// Protected routes — token required
app.get("/api/profile", authenticate, (req, res) => {
  res.json({ user: req.user });
});

app.get("/api/dashboard", authenticate, (req, res) => {
  res.json({
    message: `Welcome to your dashboard, ${req.user.name}!`,
    userId: req.user.id,
  });
});

app.get("/api/settings", authenticate, (req, res) => {
  res.json({
    user: req.user.name,
    settings: { theme: "dark", notifications: true },
  });
});
Enter fullscreen mode Exit fullscreen mode

Every route with authenticate as a second argument requires a valid JWT. Without it, the user gets a 401 or 403 error. With it, req.user contains the decoded token data.


Role-Based Access Control

Once you have JWT auth working, adding role-based access is just one more middleware:

function authorize(...allowedRoles) {
  return (req, res, next) => {
    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        error: "Forbidden — you don't have permission to access this resource",
      });
    }
    next();
  };
}

// Only admins
app.get("/api/admin/users", authenticate, authorize("admin"), (req, res) => {
  res.json({ users: users.map((u) => ({ id: u.id, name: u.name, email: u.email })) });
});

// Admins and moderators
app.delete("/api/posts/:id", authenticate, authorize("admin", "moderator"), (req, res) => {
  res.json({ message: `Post ${req.params.id} deleted` });
});

// Any authenticated user
app.get("/api/profile", authenticate, (req, res) => {
  res.json({ user: req.user });
});
Enter fullscreen mode Exit fullscreen mode

The middleware chain: authenticate → authorize → route handler. Each layer adds a check.


Complete Working Example

Here's the full server with registration, login, and protected routes:

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

const app = express();
app.use(express.json());

const JWT_SECRET = "my-secret-key";
const users = [];

// Auth middleware
function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return res.status(401).json({ error: "No token provided" });
  }

  try {
    const token = authHeader.split(" ")[1];
    req.user = jwt.verify(token, JWT_SECRET);
    next();
  } catch (err) {
    res.status(403).json({ error: "Invalid or expired token" });
  }
}

// Register
app.post("/register", async (req, res) => {
  const { name, email, password } = req.body;
  if (!name || !email || !password) {
    return res.status(400).json({ error: "All fields required" });
  }
  if (users.find((u) => u.email === email)) {
    return res.status(400).json({ error: "Email already exists" });
  }

  const hashed = await bcrypt.hash(password, 10);
  const user = { id: users.length + 1, name, email, password: hashed, role: "member" };
  users.push(user);

  res.status(201).json({ message: "Registered!", user: { id: user.id, name, email } });
});

// Login
app.post("/login", async (req, res) => {
  const { email, password } = req.body;
  const user = users.find((u) => u.email === email);
  if (!user || !(await bcrypt.compare(password, user.password))) {
    return res.status(401).json({ error: "Invalid credentials" });
  }

  const token = jwt.sign(
    { id: user.id, name: user.name, email: user.email, role: user.role },
    JWT_SECRET,
    { expiresIn: "24h" },
  );

  res.json({ message: "Login successful!", token });
});

// Protected routes
app.get("/api/profile", authenticate, (req, res) => {
  res.json({ user: req.user });
});

app.get("/api/dashboard", authenticate, (req, res) => {
  res.json({ message: `Welcome, ${req.user.name}!`, role: req.user.role });
});

// Public route
app.get("/", (req, res) => {
  res.json({ message: "Welcome! Please register or login." });
});

app.listen(3000, () => console.log("Server running on http://localhost:3000"));
Enter fullscreen mode Exit fullscreen mode

Testing the Full Flow

# 1. Register
curl -X POST http://localhost:3000/register \
  -H "Content-Type: application/json" \
  -d '{"name":"Pratham","email":"pratham@dev.in","password":"pass123"}'

# 2. Login (copy the token from the response)
curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email":"pratham@dev.in","password":"pass123"}'

# 3. Access protected route (paste your token)
curl http://localhost:3000/api/profile \
  -H "Authorization: Bearer YOUR_TOKEN_HERE"

# 4. Try without token (should get 401)
curl http://localhost:3000/api/profile
Enter fullscreen mode Exit fullscreen mode

Let's Practice: Hands-On Assignment

Part 1: Build the Auth System

Create a server with:

  • POST /register — hash password with bcrypt, save user
  • POST /login — verify credentials, return JWT
  • GET /api/profile — protected, return user data from token

Part 2: Add Token Expiry Handling

Set tokens to expire in 1 minute ({ expiresIn: "1m" }). Login, use the token, wait 2 minutes, try again — observe the "Token expired" error. Then change it to a reasonable time like 24h.

Part 3: Add Role-Based Access

Add a role field to users ("member" or "admin"). Create an authorize middleware that checks roles. Make a route like GET /api/admin that only admins can access.

Part 4: Decode a JWT

Go to jwt.io, paste your token, and see the decoded header and payload. Notice that the payload is fully readable — this is why you never put passwords in a JWT.


Key Takeaways

  1. Authentication verifies identity. JWT is a token-based approach where the server creates a signed token at login and the client sends it with every request.
  2. JWT has three parts: Header (algorithm), Payload (user data — readable by anyone), and Signature (proof of authenticity — only the server can create/verify).
  3. The login flow: Client sends credentials → Server validates → Server creates JWT → Client stores token → Client sends token with every protected request.
  4. Protect routes with middleware that extracts the token from the Authorization header, verifies it with jwt.verify(), and attaches the decoded user to req.user.
  5. JWTs are stateless — the server stores nothing. This makes them perfect for APIs and microservices, but means tokens can't be truly revoked before expiry.

Wrapping Up

JWT authentication is the standard for modern APIs. Once you build this login → token → protected route flow once, every future project follows the same pattern. Register, login, get token, send token, verify token — that's the entire system.

I'm learning all of this through the ChaiCode Web Dev Cohort 2026 under Hitesh Chaudhary and Piyush Garg. Building a real JWT auth system was the first time I felt like I was writing production-level backend code. It's the foundation for everything — user accounts, protected dashboards, role-based access, API security.

Connect with me on LinkedIn or visit PrathamDEV.in. More articles on the way.

Happy coding! 🚀


Written by Pratham Bhardwaj | Web Dev Cohort 2026, ChaiCode

Top comments (0)