Skip to main content

Command Palette

Search for a command to run...

JWT Authentication in Node.js: A Complete Developer Guide

Updated
8 min read
JWT Authentication in Node.js: A Complete Developer Guide

JWT Authentication in Node.js: A Complete Developer Guide

Audience: This post assumes working knowledge of Node.js and basic HTTP concepts. No prior auth experience required.

TL;DR: JWT lets your server issue a signed token at login that the client sends on every subsequent request. The server validates the signature — no session storage needed. This guide shows exactly how to implement that in Node.js from scratch.


Problem

Every web application eventually needs to answer one question: Who is making this request, and are they allowed to?

Without authentication, any user can access any route. Without a reliable mechanism to carry identity across requests, you're either storing sessions in memory (which breaks across multiple servers) or hitting your database on every request to verify credentials.

HTTP is stateless by design. Each request knows nothing about the previous one. Authentication systems exist to bridge that gap — to attach verified identity to stateless requests.


Solution

What Authentication Actually Means

Authentication answers: Are you who you claim to be? Authorization answers: Are you allowed to do what you're trying to do?

This post focuses on authentication — specifically, token-based stateless authentication using JWT.

In a traditional session-based system:

  1. User logs in → server creates a session in memory or DB
  2. Server sends a session ID cookie
  3. On every request, server looks up the session ID to identify the user

This works, but it has a cost: every request requires a database or cache lookup. Scale to multiple servers and you need sticky sessions or a shared session store.

With JWT:

  1. User logs in → server creates a signed token containing the user's identity
  2. Server sends the token to the client
  3. On every request, the client sends the token — server verifies the signature without any DB lookup

The server doesn't store anything. The token carries the proof.


What Is a JWT?

A JSON Web Token is a compact, URL-safe string made of three Base64URL-encoded parts separated by dots:

header.payload.signature

A real JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJ1c2VySWQiOiI4MjMiLCJlbWFpbCI6ImFsaWNlQGV4YW1wbGUuY29tIiwiaWF0IjoxNzE1MDAwMDAwLCJleHAiOjE3MTUwODY0MDB9
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Part 1: Header

Specifies the token type and signing algorithm.

{
  "alg": "HS256",
  "typ": "JWT"
}

HS256 means HMAC with SHA-256 — a symmetric algorithm where the same secret is used to sign and verify.

Part 2: Payload

Contains claims — statements about the user and the token itself.

{
  "userId": "823",
  "email": "alice@example.com",
  "iat": 1715000000,
  "exp": 1715086400
}
  • iat — issued at (Unix timestamp)
  • exp — expiration time (Unix timestamp)
  • Everything else is custom data you add

Important: The payload is Base64URL-encoded, not encrypted. Anyone can decode it. Never put passwords or sensitive secrets in the payload.

Part 3: Signature

This is what makes JWT trustworthy.

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  SECRET_KEY
)

The server creates the signature using a secret key only it knows. When the token comes back, the server recomputes the signature and compares it. If they match, the payload hasn't been tampered with. If someone modifies the payload, the signature breaks.


JWT Login Authentication Flow

Client                          Server
  |                               |
  |  POST /login {email, password} |
  |------------------------------> |
  |                               |  1. Verify credentials against DB
  |                               |  2. Create JWT with userId in payload
  |                               |  3. Sign with SECRET_KEY
  |  { token: "eyJ..." }          |
  |<------------------------------ |
  |                               |
  |  GET /profile                 |
  |  Authorization: Bearer eyJ...  |
  |------------------------------> |
  |                               |  4. Decode token
  |                               |  5. Verify signature
  |                               |  6. Check expiry
  |                               |  7. Extract userId from payload
  |  { name: "Alice", ... }       |
  |<------------------------------ |

Step 1: Project Setup

Install dependencies:

mkdir jwt-auth-demo && cd jwt-auth-demo
npm init -y
npm install express jsonwebtoken bcryptjs dotenv

Create a .env file:

JWT_SECRET=your_super_secret_key_change_this_in_production
JWT_EXPIRES_IN=24h
PORT=3000

Step 2: In-Memory User Store (Simulating a Database)

For this demo, we use an in-memory array. In production, this would be a real database query.

// users.js
const bcrypt = require('bcryptjs');

// Pre-hashed password for "password123"
const users = [
  {
    id: '1',
    name: 'Alice Johnson',
    email: 'alice@example.com',
    // bcrypt hash of "password123"
    password: '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi'
  }
];

const findUserByEmail = (email) => {
  return users.find(user => user.email === email) || null;
};

const findUserById = (id) => {
  return users.find(user => user.id === id) || null;
};

module.exports = { findUserByEmail, findUserById };

Step 3: Auth Middleware — The Gatekeeper

This middleware runs before any protected route. It extracts the token from the Authorization header, verifies it, and attaches the decoded user to req.user.

// middleware/authenticate.js
const jwt = require('jsonwebtoken');
require('dotenv').config();

const authenticate = (req, res, next) => {
  const authHeader = req.headers['authorization'];

  // Header format: "Bearer <token>"
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({
      error: 'Missing or malformed Authorization header'
    });
  }

  const token = authHeader.split(' ')[1];

  try {
    // jwt.verify throws if the token is invalid or expired
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // Attach decoded payload to request for downstream handlers
    req.user = decoded;
    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token has expired' });
    }
    if (error.name === 'JsonWebTokenError') {
      return res.status(401).json({ error: 'Invalid token' });
    }
    return res.status(500).json({ error: 'Token verification failed' });
  }
};

module.exports = authenticate;

Why jwt.verify and not jwt.decode? jwt.decode only decodes without checking the signature — it accepts any token, valid or tampered. Always use jwt.verify on the server.


Step 4: Routes — Login, Profile, and Logout

// routes/auth.js
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const { findUserByEmail, findUserById } = require('../users');
const authenticate = require('../middleware/authenticate');
require('dotenv').config();

const router = express.Router();

// POST /auth/login
// Accepts email + password, returns a signed JWT
router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password are required' });
  }

  const user = findUserByEmail(email);

  if (!user) {
    // Deliberately vague — don't reveal whether email exists
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const isPasswordValid = await bcrypt.compare(password, user.password);

  if (!isPasswordValid) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Sign the token — only include non-sensitive identity data
  const token = jwt.sign(
    {
      userId: user.id,
      email: user.email
    },
    process.env.JWT_SECRET,
    { expiresIn: process.env.JWT_EXPIRES_IN }
  );

  return res.status(200).json({
    message: 'Login successful',
    token
  });
});

// GET /auth/profile — protected route
// Requires valid JWT in Authorization header
router.get('/profile', authenticate, (req, res) => {
  // req.user was set by the authenticate middleware
  const user = findUserById(req.user.userId);

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

  return res.status(200).json({
    id: user.id,
    name: user.name,
    email: user.email
  });
});

module.exports = router;

Step 5: Express App Entry Point

// app.js
const express = require('express');
const authRoutes = require('./routes/auth');
require('dotenv').config();

const app = express();

app.use(express.json());
app.use('/auth', authRoutes);

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

module.exports = app;

Step 6: Testing the Flow

Start the server:

node app.js
# Server running on http://localhost:3000

Login and get a token:

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

Expected response:

{
  "message": "Login successful",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxIiwiZW1haWwiOiJhbGljZUBleGFtcGxlLmNvbSIsImlhdCI6MTcxNTAwMDAwMCwiZXhwIjoxNzE1MDg2NDAwfQ.abc123signature"
}

Access a protected route:

curl http://localhost:3000/auth/profile \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

Expected response:

{
  "id": "1",
  "name": "Alice Johnson",
  "email": "alice@example.com"
}

Access without a token:

curl http://localhost:3000/auth/profile

Expected response:

{
  "error": "Missing or malformed Authorization header"
}

Token Validation Lifecycle

Incoming Request with Token
          |
          v
   Extract from Header
          |
          v
   Is header present? --No--> 401 Unauthorized
          |
         Yes
          |
          v
   jwt.verify(token, secret)
          |
     /----+----\
    /            \
Signature      Signature
Invalid         Valid
    |               |
  401            Check expiry
                    |
              /-----+-----\
            Expired      Not Expired
              |               |
            401           Attach req.user
                              |
                          Next() → Route Handler

Results

  • Zero database calls for authentication on every protected request — the signature verification is a CPU operation, not an I/O operation
  • Horizontal scaling works out of the box — any server instance can verify any token as long as it has the secret
  • Stateless — no session table, no Redis session store required for basic auth
  • Typical JWT verification with jsonwebtoken takes under 1ms on modern hardware

Trade-offs

ConcernReality
Token revocationJWTs can't be invalidated before expiry without a blocklist (which reintroduces state). Use short expiry windows (15–60 min) + refresh tokens for sensitive apps.
Payload sizeEvery request carries the token. Keep payload small — don't store full user objects in it.
Secret key managementIf your JWT_SECRET leaks, all tokens are compromised. Use environment secrets management (AWS Secrets Manager, Vault, etc.) in production.
Payload is readableBase64 is encoding, not encryption. Never put PII or secrets in the JWT payload unless you use JWE (JSON Web Encryption).
Algorithm confusionAlways specify the expected algorithm in jwt.verify. Don't accept alg: none. The jsonwebtoken library handles this correctly by default.

When NOT to Use JWT This Way

  • High-security sessions where immediate revocation is critical (banking, medical) — use opaque session tokens with a server-side store
  • Very long-lived tokens — a 30-day JWT that gets stolen has a 30-day attack window. Short-lived tokens with refresh token rotation are safer
  • Storing sensitive data — if you need to store sensitive claims, use JWE, not plain JWT

Conclusion

JWT authentication gives you stateless, scalable identity verification at the cost of losing instant revocation capability. The implementation is straightforward: sign a token at login with a secret key, send it to the client, verify it on every protected request.

For most APIs, the pattern in this guide covers the core use case. The natural next step is adding refresh tokens — short-lived access tokens paired with longer-lived refresh tokens — to balance security and user experience.

Next step: Implement a POST /auth/refresh endpoint that accepts a refresh token (stored in an httpOnly cookie) and issues a new short-lived access token.


Further Reading

JWT Authentication in Node.js: Complete Guide