Skip to main content

Command Palette

Search for a command to run...

JWT Authentication in Node.js Explained Simply

Updated
8 min read
JWT Authentication in Node.js Explained Simply
H
CS undergrad | Tech enthusiast | Focusing on Web Dev • DSA • ML | Building skills for real-world impact

Apps need to know who's using them. But checking a database on every request is slow. JWT lets you prove who you are with a signed token the server can verify instantly.

This is about understanding JWT and building login systems without session databases.


What Authentication Means

Authentication answers: "Who are you?"

Real Example

Checking into a hotel: you show an ID once, and they don't check the guest list every time you walk in. The ID is your proof.

Apps work the same way.

Without Authentication

app.get("/profile", (req, res) => {
  res.json({ message: "Anyone can access this" });
});

No protection.

With Authentication

app.get("/profile", (req, res) => {
  if (!req.user) {
    return res.status(401).json({ error: "Not authenticated" });
  }
  res.json({ message: `Hello ${req.user.name}` });
});

Only authenticated users see it.


What JWT Is

JWT (JSON Web Token) is a token that represents a user's identity.

The Concept

  1. User logs in
  2. Server creates a token containing their info
  3. Server sends the token
  4. User sends the token with every request
  5. Server verifies the token (no database needed)

The token itself proves who they are.

Simple Analogy

Concert wristband: instead of the venue checking a list every time you enter, you wear a wristband proving you're authorized. The wristband is the proof.

JWT is like that wristband for API requests.

JWT vs Sessions

Sessions JWT
Stored on server Stored on client
Database lookup Token contains data
One server only Works across servers
Can revoke immediately Valid until expiration

Structure of a JWT

A JWT has three parts: header.payload.signature

Part 1: Header

{
  "alg": "HS256",    // Algorithm
  "typ": "JWT"       // Type
}

Base64 encoded: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

Part 2: Payload

{
  "userId": 1,
  "email": "alice@example.com",
  "name": "Alice",
  "iat": 1234567890,    // Issued at
  "exp": 1234571490     // Expires at
}

Base64 encoded: eyJ1c2VySWQiOjEsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20ifQ

Part 3: Signature

Server signs the header and payload:

HMACSHA256(
  base64(header) + "." + base64(payload),
  "secret-key"
)

Result: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Complete Token

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjE...SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Three parts separated by dots.

Why the Signature Matters

It proves the token hasn't been tampered with:

Valid token: signature matches
Tampered token: signature doesn't match → rejected

JWT Token Structure Visualization

JWT Token
  |
  ├─ Header (base64)
  |   ├─ Algorithm: HS256
  |   └─ Type: JWT
  |
  ├─ Payload (base64)
  |   ├─ userId: 1
  |   ├─ email: alice@example.com
  |   ├─ name: Alice
  |   ├─ iat: 1234567890
  |   └─ exp: 1234571490
  |
  └─ Signature (HMAC)
      └─ Hash of header + payload with secret

The server can read the payload without a database because it's right there in the token.


Login Flow Using JWT

Step 1: User Logs In

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

Step 2: Server Creates JWT

const jwt = require("jsonwebtoken");
const SECRET = "your-secret-key";

app.post("/login", (req, res) => {
  const { email, password } = req.body;
  const user = users.find(u => u.email === email);
  
  if (!user || user.password !== password) {
    return res.status(401).json({ error: "Invalid credentials" });
  }
  
  // Create token
  const token = jwt.sign(
    { userId: user.id, email: user.email, name: user.name },
    SECRET,
    { expiresIn: "24h" }
  );
  
  res.json({ token });
});

Token contains user info and expires in 24 hours.

Step 3: Client Stores Token

// After login, client stores token
localStorage.setItem("token", token);

Step 4: Client Sends Token With Requests

// Every request includes the token
const token = localStorage.getItem("token");

fetch("http://localhost:3000/profile", {
  headers: {
    "Authorization": `Bearer ${token}`
  }
});

Step 5: Server Verifies Token

app.get("/profile", (req, res) => {
  const token = req.headers.authorization?.split(" ")[1];
  
  if (!token) {
    return res.status(401).json({ error: "No token" });
  }
  
  try {
    const decoded = jwt.verify(token, SECRET);
    res.json({ message: `Hello ${decoded.name}` });
  } catch (error) {
    res.status(401).json({ error: "Invalid token" });
  }
});

No database needed. The token proves who they are.


JWT Login Authentication Flow

User sends login request
(email + password)
      |
      v
Server finds user in database
      |
      v
Server checks password
      |
      ├─ Wrong: send error
      └─ Correct: continue
      |
      v
Server creates JWT token
(contains user info)
      |
      v
Server sends token to client
      |
      v
Client stores token
(localStorage, sessionStorage, etc)
      |
      v
User makes API request
(includes token in Authorization header)
      |
      v
Server verifies token signature
      |
      ├─ Invalid: reject (401)
      └─ Valid: continue
      |
      v
Request allowed, response sent

Sending Token With Requests

In HTTP Header (Standard)

// Client side
const token = localStorage.getItem("token");

fetch("http://localhost:3000/profile", {
  headers: {
    "Authorization": `Bearer ${token}`
  }
});

This is the standard approach.

Alternative (automatically sent):

// Server side
res.cookie("token", token, {
  httpOnly: true,
  secure: true,
  maxAge: 24 * 60 * 60 * 1000
});

In Query String

Not recommended (exposes token in logs):

fetch("http://localhost:3000/profile?token=" + token);

The header approach is best.


Protecting Routes Using Tokens

Token Verification Middleware

function verifyToken(req, res, next) {
  const token = req.headers.authorization?.split(" ")[1];
  
  if (!token) {
    return res.status(401).json({ error: "No token" });
  }
  
  try {
    const decoded = jwt.verify(token, SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    res.status(401).json({ error: "Invalid token" });
  }
}

Apply to Routes

// Protected route
app.get("/profile", verifyToken, (req, res) => {
  res.json({ 
    message: `Hello ${req.user.name}`,
    user: req.user
  });
});

// Another protected route
app.get("/posts", verifyToken, (req, res) => {
  res.json({ posts: "User's posts" });
});

// Public route (no middleware)
app.get("/public", (req, res) => {
  res.json({ message: "Anyone can see this" });
});

The middleware checks the token before the handler runs.

Admin-Only Route

function requireAdmin(req, res, next) {
  if (req.user.role !== "admin") {
    return res.status(403).json({ error: "Admin only" });
  }
  next();
}

app.delete("/users/:id", verifyToken, requireAdmin, (req, res) => {
  res.json({ message: "User deleted" });
});

Complete Login Example

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

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

const SECRET = "your-secret-key";

// In-memory user store
const users = [
  { id: 1, email: "alice@example.com", password: "password123", name: "Alice" },
  { id: 2, email: "bob@example.com", password: "password456", name: "Bob" }
];

// Login endpoint
app.post("/login", (req, res) => {
  const { email, password } = req.body;
  
  // Find user
  const user = users.find(u => u.email === email);
  
  // Check password
  if (!user || user.password !== password) {
    return res.status(401).json({ error: "Invalid credentials" });
  }
  
  // Create token
  const token = jwt.sign(
    { userId: user.id, email: user.email, name: user.name },
    SECRET,
    { expiresIn: "24h" }
  );
  
  res.json({ token });
});

// Verify token middleware
function verifyToken(req, res, next) {
  const token = req.headers.authorization?.split(" ")[1];
  
  if (!token) {
    return res.status(401).json({ error: "No token" });
  }
  
  try {
    const decoded = jwt.verify(token, SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    res.status(401).json({ error: "Invalid token" });
  }
}

// Protected route
app.get("/profile", verifyToken, (req, res) => {
  res.json({ message: `Hello ${req.user.name}`, user: req.user });
});

// Public route
app.get("/public", (req, res) => {
  res.json({ message: "Public data" });
});

app.listen(3000, () => {
  console.log("Server running on http://localhost:3000");
});

Test It

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

# Response: {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

# Access protected route with token
curl http://localhost:3000/profile \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

# Response: {"message":"Hello Alice","user":{"userId":1,...}}

Practice Assignment

1. Create a login endpoint:

// Receive email and password
// Find user in array
// Create JWT if credentials valid
// Return token

2. Build token verification middleware:

// Extract token from Authorization header
// Verify signature with jwt.verify()
// Store decoded user in req.user
// Call next() if valid

3. Protect a route:

// Apply verifyToken middleware to a route
// Route should access req.user
// Test with curl including token

4. Handle token expiration:

// Set expiresIn to "1h"
// Try accessing route after expiration
// See "invalid token" error

Quick Recap

  • Authentication proves who someone is.

  • JWT is a token containing user info, signed by the server.

  • JWT has three parts: header.payload.signature

  • Header specifies the algorithm.

  • Payload contains user data.

  • Signature proves the token hasn't been tampered with.

  • Login: receive credentials → verify → create token → send to client.

  • Clients send the token in the Authorization header.

  • Servers verify the token's signature, no database needed.

  • Protect routes by checking the token before allowing access.

  • JWT is stateless: the token proves who the user is.

JWT makes authentication simple and scalable.


If you enjoyed this article, check out my other blogs on this profile. 🔗 Connect with me: LinkedIn | GitHub | X (Twitter)