DEV Community

Harman Panwar
Harman Panwar

Posted on

JWT Authentication in Node.js Explained Simply

Authentication with JWT: Stateless Security for Modern Applications

Every application that stores user data needs to answer a fundamental question: "How do we know who you are?" Authentication is the process of verifying identity, and JSON Web Tokens (JWT) have become the dominant standard for implementing stateless authentication in modern web applications. This guide explains why authentication matters, how JWT works without deep cryptography, and how to implement a complete token-based login flow.


What Authentication Means

The Identity Problem

When you visit a website, the server receives an HTTP request. That request contains your IP address, browser type, and the page you want — but it does not contain your identity. HTTP is stateless: each request is independent, and the server forgets everything about you as soon as it sends a response.

Imagine a bank where every customer interaction starts with: "Who are you, and what is your account number?" You would have to present your ID and account card for every single transaction — checking balance, withdrawing cash, depositing a check. That is how HTTP works by default.

Authentication solves this by answering: "Is this person really who they claim to be?"

Why Authentication Is Required

Without authentication, applications cannot:

Capability Why It Needs Authentication
Personalized content Show Alice her dashboard, not Bob's
Data protection Prevent strangers from reading private messages
Authorization Ensure only admins can delete accounts
Audit trails Track who performed which action
Session continuity Keep users logged in across page loads

Authentication vs Authorization

These terms are often confused but solve different problems:

Concept Question It Answers Example
Authentication "Who are you?" Verifying your password at login
Authorization "What are you allowed to do?" Checking if you can access the admin panel

You must authenticate (prove identity) before you can be authorized (granted permissions).


The Session Problem: Stateful Authentication

Traditional Session-Based Authentication

Before JWT, most applications used session-based authentication:

1. User submits username + password
2. Server validates credentials
3. Server creates a session record in memory or database
4. Server sends a session ID cookie to the browser
5. Browser sends the cookie with every subsequent request
6. Server looks up the session ID in its storage to identify the user
Enter fullscreen mode Exit fullscreen mode

The problem: The server must store session data somewhere — in memory, Redis, or a database. Every request requires a database lookup to find who owns that session ID.

Drawbacks:

  • Storage overhead: Millions of logged-in users = millions of session records
  • Scaling complexity: Session storage must be shared across multiple servers
  • Sticky sessions: Load balancers must route users to the same server holding their session
  • Cleanup: Expired sessions must be purged regularly

This is stateful authentication: the server maintains state (session data) for every active user.


Stateless Authentication: The JWT Solution

What "Stateless" Means

JWT enables stateless authentication: the server does not store any session data. Instead, all the information needed to verify a user is contained within the token itself. The server only needs to validate the token's integrity — no database lookups required.

Think of it like a tamper-evident concert wristband:

  • At entry, the venue checks your ticket and gives you a wristband (JWT)
  • The wristband contains your seat section and access level (payload)
  • It has a special seal that proves it is genuine (signature)
  • At every checkpoint, staff just inspect the wristband — they do not look up your name in a database
  • If the seal is broken, the wristband is rejected immediately

What JWT Is

JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact, self-contained way to transmit information between parties as a JSON object. The information is digitally signed, making it verifiable and trustworthy.

A JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NSIsInJvbGUiOiJ1c2VyIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Enter fullscreen mode Exit fullscreen mode

It is just a string with three parts separated by dots. Despite the intimidating appearance, the structure is simple.


Structure of a JWT

A JWT has three parts, separated by dots (.):

xxxxx.yyyyy.zzzzz
↑      ↑      ↑
Header Payload Signature
Enter fullscreen mode Exit fullscreen mode

Part 1: Header

The header contains metadata about the token — typically the signing algorithm and token type.

{
  "alg": "HS256",
  "typ": "JWT"
}
Enter fullscreen mode Exit fullscreen mode
Field Meaning
alg Algorithm used for signing (HS256 = HMAC with SHA-256)
typ Token type (always "JWT")

This JSON is Base64Url encoded into the first part of the token. Base64Url encoding is just a way to convert JSON text into a URL-safe string — it is not encryption, and anyone can decode it.

Part 2: Payload

The payload contains the claims — statements about the user and additional data.

{
  "sub": "1234567890",
  "name": "John Doe",
  "role": "user",
  "iat": 1685123456,
  "exp": 1685728256
}
Enter fullscreen mode Exit fullscreen mode
Claim Meaning Purpose
sub Subject Identifier of the user (user ID)
name Name Human-readable name
role Role Authorization level
iat Issued At Timestamp when token was created
exp Expiration Timestamp when token becomes invalid

Important: The payload is also Base64Url encoded — not encrypted. Anyone who has the token can decode and read the payload. Never put sensitive information (passwords, credit cards, secrets) in a JWT payload.

Part 3: Signature

The signature proves the token has not been tampered with. It is created by:

  1. Taking the encoded header
  2. Taking the encoded payload
  3. Concatenating them with a dot: encodedHeader + "." + encodedPayload
  4. Running them through a signing algorithm with a secret key
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret_key
)
Enter fullscreen mode Exit fullscreen mode

The server keeps the secret key private. When a token arrives, the server recalculates the signature using the same key and algorithm. If the recalculated signature matches the token's signature, the token is authentic and unmodified.

What the signature prevents:

  • A user changing their role from "user" to "admin" in the payload
  • A user extending their exp date to never expire
  • Anyone creating fake tokens without knowing the secret key

Complete JWT Example

// A real JWT (decoded for illustration)
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NSIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNjg1MTIzNDU2fQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";

// Split into parts
const [headerB64, payloadB64, signature] = token.split('.');

// Decode header (Base64Url → JSON)
const header = JSON.parse(atob(headerB64));
// { alg: "HS256", typ: "JWT" }

// Decode payload (Base64Url → JSON)
const payload = JSON.parse(atob(payloadB64));
// { userId: "12345", role: "user", iat: 1685123456 }

// Signature cannot be "decoded" — it must be verified with the secret key
Enter fullscreen mode Exit fullscreen mode

Login Flow Using JWT

Step-by-Step Authentication Flow

┌─────────────┐                    ┌──────────────┐
│   Client    │                    │    Server    │
│  (Browser)  │                    │              │
└──────┬──────┘                    └──────┬─────┘
       │                                   │
       │  1. POST /login                   │
       │  { email, password }              │
       │ ─────────────────────────────────→│
       │                                   │
       │                                   │  2. Validate credentials
       │                                   │     (check database)
       │                                   │
       │                                   │  3. Create JWT payload
       │                                   │     { userId, role, iat, exp }
       │                                   │
       │                                   │  4. Sign with secret key
       │                                   │     → Generate signature
       │                                   │
       │  5. Return JWT                    │
       │  { token: "eyJhbG..." }           │
       │ ←─────────────────────────────────│
       │                                   │
       │  6. Store token                   │
       │     (localStorage or cookie)        │
       │                                   │
Enter fullscreen mode Exit fullscreen mode

Implementation: Login Endpoint

const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');

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

// In production, store this in environment variables
const JWT_SECRET = 'your-secret-key-change-this-in-production';

// Mock database
const users = [
  { 
    id: '1', 
    email: 'alice@example.com', 
    password: '$2b$10$...', // hashed password
    role: 'user' 
  }
];

// Login endpoint
app.post('/login', async (req, res) => {
  const { email, password } = req.body;

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

  // 2. Verify password
  const validPassword = await bcrypt.compare(password, user.password);
  if (!validPassword) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // 3. Create JWT payload
  const payload = {
    userId: user.id,
    role: user.role,
    iat: Math.floor(Date.now() / 1000)
  };

  // 4. Sign and create token (expires in 24 hours)
  const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' });

  // 5. Send token to client
  res.json({ 
    message: 'Login successful',
    token: token 
  });
});
Enter fullscreen mode Exit fullscreen mode

What Happens on the Client

// Client-side login request
async function login(email, password) {
  const response = await fetch('/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password })
  });

  const data = await response.json();

  if (response.ok) {
    // Store token for future requests
    localStorage.setItem('token', data.token);
    console.log('Login successful');
  } else {
    console.error('Login failed:', data.error);
  }
}
Enter fullscreen mode Exit fullscreen mode

Sending Token With Requests

Once the client has a token, it must send it with every request that requires authentication.

The Authorization Header

The standard way to send a JWT is via the HTTP Authorization header using the Bearer scheme:

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

Client-Side Request Example

const token = localStorage.getItem('token');

// Include token in every authenticated request
const response = await fetch('/api/profile', {
  method: 'GET',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${token}`  // ← Token attached here
  }
});

const profile = await response.json();
Enter fullscreen mode Exit fullscreen mode

Using a Request Interceptor (Axios Example)

// Automatically attach token to all requests
axios.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Now every request automatically includes the token
axios.get('/api/profile');  // Token attached automatically
axios.post('/api/orders', { items: [...] });  // Token attached automatically
Enter fullscreen mode Exit fullscreen mode

Protecting Routes Using Tokens

Middleware: The Gatekeeper

Express middleware functions run before route handlers. Authentication middleware checks the token and either allows the request to proceed or rejects it.

// Authentication middleware
function authenticateToken(req, res, next) {
  // 1. Get token from Authorization header
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"

  if (!token) {
    return res.status(401).json({ error: 'Access denied. No token provided.' });
  }

  // 2. Verify token
  jwt.verify(token, JWT_SECRET, (err, decodedPayload) => {
    if (err) {
      return res.status(403).json({ error: 'Invalid or expired token' });
    }

    // 3. Attach user info to request for route handlers to use
    req.user = decodedPayload;

    // 4. Proceed to the route handler
    next();
  });
}
Enter fullscreen mode Exit fullscreen mode

Protected Routes

Apply the middleware to any route that requires authentication:

// Public route — no authentication needed
app.get('/', (req, res) => {
  res.send('Public homepage');
});

// Public route
app.post('/login', (req, res) => {
  // ... login logic
});

// Protected route — authentication required
app.get('/api/profile', authenticateToken, (req, res) => {
  // req.user is available here because authenticateToken attached it
  const userId = req.user.userId;
  const role = req.user.role;

  const user = users.find(u => u.id === userId);

  res.json({
    id: user.id,
    email: user.email,
    role: role,
    message: 'This is protected data'
  });
});

// Protected route
app.post('/api/orders', authenticateToken, (req, res) => {
  // Only authenticated users can create orders
  const order = {
    userId: req.user.userId,
    items: req.body.items,
    createdAt: new Date()
  };

  res.status(201).json({ order });
});
Enter fullscreen mode Exit fullscreen mode

Authorization: Role-Based Access

Authentication verifies identity. Authorization checks permissions. Combine both:

// Middleware to check admin role
function requireAdmin(req, res, next) {
  if (req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Admin access required' });
  }
  next();
}

// Admin-only route
app.delete('/api/users/:id', authenticateToken, requireAdmin, (req, res) => {
  // Only admins reach this code
  const userId = req.params.id;
  // ... delete user logic
  res.json({ message: `User ${userId} deleted` });
});
Enter fullscreen mode Exit fullscreen mode

Complete Protected Server Example

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

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

const JWT_SECRET = 'your-secret-key';

// Mock users database
const users = [
  { id: '1', email: 'alice@example.com', password: 'hashed', role: 'user' },
  { id: '2', email: 'admin@example.com', password: 'hashed', role: 'admin' }
];

// Authentication middleware
const authenticateToken = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }

  jwt.verify(token, JWT_SECRET, (err, user) => {
    if (err) return res.status(403).json({ error: 'Invalid token' });
    req.user = user;
    next();
  });
};

// Routes
app.get('/', (req, res) => res.send('Public API'));

app.post('/login', (req, res) => {
  const { email } = req.body;
  const user = users.find(u => u.email === email);

  if (!user) return res.status(401).json({ error: 'User not found' });

  const token = jwt.sign(
    { userId: user.id, role: user.role },
    JWT_SECRET,
    { expiresIn: '1h' }
  );

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

app.get('/api/profile', authenticateToken, (req, res) => {
  res.json({ 
    message: 'Protected profile data',
    userId: req.user.userId,
    role: req.user.role
  });
});

app.get('/api/admin', authenticateToken, (req, res) => {
  if (req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Admin only' });
  }
  res.json({ message: 'Admin dashboard data' });
});

app.listen(3000, () => {
  console.log('Server running. Test with:');
  console.log('  curl -X POST http://localhost:3000/login -H "Content-Type: application/json" -d \'{"email":"alice@example.com"}\'');
  console.log('  curl http://localhost:3000/api/profile -H "Authorization: Bearer <token>"');
});
Enter fullscreen mode Exit fullscreen mode

JWT Security Best Practices

Practice Why It Matters
Keep the secret key secret Anyone with the key can forge tokens
Set expiration times Stolen tokens become useless after expiry
Use HTTPS Prevents token interception in transit
Don't store sensitive data in payload Payload is readable by anyone who has the token
Validate on every protected request Never trust tokens without verification
Use strong signing algorithms HS256 or RS256, not weaker alternatives
Implement token refresh Short-lived access tokens + long-lived refresh tokens

Summary

Concept What It Means
Authentication Verifying who a user is (identity proof)
Stateful auth Server stores session data in memory/database
Stateless auth Server stores nothing; all data is in the token
JWT Self-contained, signed token with header, payload, and signature
Header Metadata: algorithm and token type
Payload Claims about the user: ID, role, timestamps
Signature Proof the token was issued by the server and not tampered with
Bearer token Token sent in Authorization: Bearer <token> header
Middleware Function that runs before route handlers to verify tokens

JWT transformed authentication by eliminating server-side session storage. Instead of looking up sessions in a database on every request, the server simply verifies a signature. The token carries its own proof of authenticity. This makes applications easier to scale, simpler to deploy across multiple servers, and faster for every authenticated request. The trade-off is careful secret management and short expiration times — but those are solvable operational concerns, not architectural limitations.

Remember: A JWT is like a tamper-evident wristband at a concert. It contains your access level, is issued by a trusted authority, and has a seal that proves it is genuine. Staff at every checkpoint just inspect the band — they do not look you up in a database. Stateless, self-contained, efficient.

Top comments (0)