Nodejs

JWT Authentication in Express.js Applications

A practical guide to implementing JWT authentication in Express.js covering token generation, refresh tokens, middleware protection, password hashing, and security best practices.

JWT Authentication in Express.js Applications

JSON Web Tokens (JWT) are the standard authentication mechanism for Express.js APIs. A JWT is a signed, self-contained token that carries user identity information. The server generates a token when the user logs in, and the client sends it with every subsequent request. The server verifies the token's signature without hitting the database — making authentication fast and stateless.

This guide covers implementing JWT authentication from scratch in an Express.js application: user registration, login, token generation, refresh tokens, middleware protection, and the security considerations that keep the system robust.

How JWT Works

A JWT has three parts separated by dots:

eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZW1haWwiOiJhbGljZUBleGFtcGxlLmNvbSJ9.signature
  1. Header — the signing algorithm (HS256, RS256)
  2. Payload — the claims (user data, expiration time)
  3. Signature — verifies the token has not been tampered with

The server signs the token with a secret key. When a client sends the token back, the server verifies the signature using the same key. If someone modifies the payload, the signature check fails.

JWTs are not encrypted — they are base64-encoded. Anyone can read the payload. Never store sensitive data (passwords, credit card numbers) in the token.

Setup

npm install express jsonwebtoken bcrypt

Project Structure

auth/
  token.js          # Token generation and verification
middleware/
  authenticate.js   # JWT authentication middleware
routes/
  auth.js           # Login, register, refresh routes
  protected.js      # Protected API routes
models/
  userModel.js      # User database operations
app.js              # Express application
server.js           # HTTP server

User Model

// models/userModel.js
var db = require("../db");
var bcrypt = require("bcrypt");

var SALT_ROUNDS = 12;

function createUser(email, name, password) {
  return bcrypt.hash(password, SALT_ROUNDS)
    .then(function(hash) {
      return db.query(
        "INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, email, name, role, created_at",
        [email, name, hash]
      );
    })
    .then(function(result) {
      return result.rows[0];
    });
}

function findByEmail(email) {
  return db.query("SELECT * FROM users WHERE email = $1", [email])
    .then(function(result) {
      return result.rows[0] || null;
    });
}

function findById(id) {
  return db.query(
    "SELECT id, email, name, role, created_at FROM users WHERE id = $1",
    [id]
  ).then(function(result) {
    return result.rows[0] || null;
  });
}

function verifyPassword(plaintext, hash) {
  return bcrypt.compare(plaintext, hash);
}

module.exports = {
  createUser: createUser,
  findByEmail: findByEmail,
  findById: findById,
  verifyPassword: verifyPassword
};

Database Schema

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  email VARCHAR(255) UNIQUE NOT NULL,
  name VARCHAR(255) NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  role VARCHAR(50) DEFAULT 'user',
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE TABLE refresh_tokens (
  id SERIAL PRIMARY KEY,
  user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
  token VARCHAR(500) NOT NULL,
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
  revoked BOOLEAN DEFAULT false,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_refresh_tokens_token ON refresh_tokens (token);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens (user_id);

Token Generation

// auth/token.js
var jwt = require("jsonwebtoken");
var crypto = require("crypto");
var db = require("../db");

var ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
var REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
var ACCESS_EXPIRES = "15m";
var REFRESH_EXPIRES = "7d";

if (!ACCESS_SECRET || !REFRESH_SECRET) {
  throw new Error("JWT secrets must be set in environment variables");
}

function generateAccessToken(user) {
  return jwt.sign(
    {
      id: user.id,
      email: user.email,
      role: user.role
    },
    ACCESS_SECRET,
    { expiresIn: ACCESS_EXPIRES }
  );
}

function generateRefreshToken(user) {
  var token = crypto.randomBytes(40).toString("hex");
  var expiresAt = new Date();
  expiresAt.setDate(expiresAt.getDate() + 7); // 7 days

  return db.query(
    "INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES ($1, $2, $3) RETURNING token",
    [user.id, token, expiresAt]
  ).then(function(result) {
    return result.rows[0].token;
  });
}

function verifyAccessToken(token) {
  return jwt.verify(token, ACCESS_SECRET);
}

function verifyRefreshToken(token) {
  return db.query(
    "SELECT * FROM refresh_tokens WHERE token = $1 AND revoked = false AND expires_at > NOW()",
    [token]
  ).then(function(result) {
    if (result.rows.length === 0) return null;
    return result.rows[0];
  });
}

function revokeRefreshToken(token) {
  return db.query(
    "UPDATE refresh_tokens SET revoked = true WHERE token = $1",
    [token]
  );
}

function revokeAllUserTokens(userId) {
  return db.query(
    "UPDATE refresh_tokens SET revoked = true WHERE user_id = $1",
    [userId]
  );
}

module.exports = {
  generateAccessToken: generateAccessToken,
  generateRefreshToken: generateRefreshToken,
  verifyAccessToken: verifyAccessToken,
  verifyRefreshToken: verifyRefreshToken,
  revokeRefreshToken: revokeRefreshToken,
  revokeAllUserTokens: revokeAllUserTokens
};

Why Two Tokens

Access tokens are short-lived (15 minutes). They carry user data in the payload and are verified without a database query. Short expiration limits the damage if a token is stolen.

Refresh tokens are long-lived (7 days). They are stored in the database and can be revoked. When the access token expires, the client uses the refresh token to get a new one.

This two-token system balances security (short access token lifetime) with usability (users do not re-enter credentials every 15 minutes).

Authentication Middleware

// middleware/authenticate.js
var tokenService = require("../auth/token");

function authenticate(req, res, next) {
  var header = req.headers.authorization;

  if (!header) {
    return res.status(401).json({
      error: { message: "Authorization header required", code: "NO_TOKEN" }
    });
  }

  var parts = header.split(" ");
  if (parts.length !== 2 || parts[0] !== "Bearer") {
    return res.status(401).json({
      error: { message: "Format: Bearer <token>", code: "INVALID_FORMAT" }
    });
  }

  try {
    var decoded = tokenService.verifyAccessToken(parts[1]);
    req.user = decoded;
    next();
  } catch (err) {
    if (err.name === "TokenExpiredError") {
      return res.status(401).json({
        error: { message: "Token expired", code: "TOKEN_EXPIRED" }
      });
    }
    return res.status(401).json({
      error: { message: "Invalid token", code: "INVALID_TOKEN" }
    });
  }
}

module.exports = authenticate;

Auth Routes

Registration

// routes/auth.js
var express = require("express");
var router = express.Router();
var userModel = require("../models/userModel");
var tokenService = require("../auth/token");

router.post("/register", function(req, res) {
  var email = req.body.email;
  var name = req.body.name;
  var password = req.body.password;

  // Validate input
  var errors = {};
  if (!email || !email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
    errors.email = "Valid email is required";
  }
  if (!name || name.length < 2) {
    errors.name = "Name must be at least 2 characters";
  }
  if (!password || password.length < 8) {
    errors.password = "Password must be at least 8 characters";
  }

  if (Object.keys(errors).length > 0) {
    return res.status(400).json({ error: { message: "Validation failed", fields: errors } });
  }

  // Check if email already exists
  userModel.findByEmail(email)
    .then(function(existing) {
      if (existing) {
        return res.status(409).json({
          error: { message: "Email already registered", code: "EMAIL_EXISTS" }
        });
      }

      return userModel.createUser(email, name, password)
        .then(function(user) {
          var accessToken = tokenService.generateAccessToken(user);

          return tokenService.generateRefreshToken(user)
            .then(function(refreshToken) {
              res.status(201).json({
                user: { id: user.id, email: user.email, name: user.name, role: user.role },
                accessToken: accessToken,
                refreshToken: refreshToken
              });
            });
        });
    })
    .catch(function(err) {
      console.error("Registration error:", err);
      res.status(500).json({ error: { message: "Registration failed" } });
    });
});

Login

router.post("/login", function(req, res) {
  var email = req.body.email;
  var password = req.body.password;

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

  userModel.findByEmail(email)
    .then(function(user) {
      if (!user) {
        return res.status(401).json({
          error: { message: "Invalid credentials", code: "INVALID_CREDENTIALS" }
        });
      }

      return userModel.verifyPassword(password, user.password_hash)
        .then(function(match) {
          if (!match) {
            return res.status(401).json({
              error: { message: "Invalid credentials", code: "INVALID_CREDENTIALS" }
            });
          }

          var accessToken = tokenService.generateAccessToken(user);

          return tokenService.generateRefreshToken(user)
            .then(function(refreshToken) {
              res.json({
                user: { id: user.id, email: user.email, name: user.name, role: user.role },
                accessToken: accessToken,
                refreshToken: refreshToken
              });
            });
        });
    })
    .catch(function(err) {
      console.error("Login error:", err);
      res.status(500).json({ error: { message: "Login failed" } });
    });
});

Token Refresh

router.post("/refresh", function(req, res) {
  var refreshToken = req.body.refreshToken;

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

  tokenService.verifyRefreshToken(refreshToken)
    .then(function(tokenRecord) {
      if (!tokenRecord) {
        return res.status(401).json({
          error: { message: "Invalid or expired refresh token", code: "INVALID_REFRESH" }
        });
      }

      return userModel.findById(tokenRecord.user_id)
        .then(function(user) {
          if (!user) {
            return res.status(401).json({
              error: { message: "User not found", code: "USER_NOT_FOUND" }
            });
          }

          // Rotate: revoke old refresh token, issue new one
          return tokenService.revokeRefreshToken(refreshToken)
            .then(function() {
              var newAccessToken = tokenService.generateAccessToken(user);
              return tokenService.generateRefreshToken(user)
                .then(function(newRefreshToken) {
                  res.json({
                    accessToken: newAccessToken,
                    refreshToken: newRefreshToken
                  });
                });
            });
        });
    })
    .catch(function(err) {
      console.error("Token refresh error:", err);
      res.status(500).json({ error: { message: "Token refresh failed" } });
    });
});

Logout

var authenticate = require("../middleware/authenticate");

router.post("/logout", authenticate, function(req, res) {
  var refreshToken = req.body.refreshToken;

  if (refreshToken) {
    tokenService.revokeRefreshToken(refreshToken)
      .then(function() {
        res.json({ message: "Logged out" });
      })
      .catch(function(err) {
        console.error("Logout error:", err);
        res.json({ message: "Logged out" });
      });
  } else {
    res.json({ message: "Logged out" });
  }
});

// Logout from all devices
router.post("/logout-all", authenticate, function(req, res) {
  tokenService.revokeAllUserTokens(req.user.id)
    .then(function() {
      res.json({ message: "Logged out from all devices" });
    })
    .catch(function(err) {
      console.error("Logout-all error:", err);
      res.status(500).json({ error: { message: "Failed to logout" } });
    });
});

Protected Routes

// routes/protected.js
var express = require("express");
var router = express.Router();
var authenticate = require("../middleware/authenticate");

// All routes in this router require authentication
router.use(authenticate);

router.get("/profile", function(req, res) {
  var userModel = require("../models/userModel");

  userModel.findById(req.user.id)
    .then(function(user) {
      if (!user) {
        return res.status(404).json({ error: { message: "User not found" } });
      }
      res.json({ user: user });
    })
    .catch(function(err) {
      res.status(500).json({ error: { message: "Failed to load profile" } });
    });
});

router.put("/profile", function(req, res) {
  var name = req.body.name;
  var db = require("../db");

  db.query(
    "UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2 RETURNING id, email, name, role",
    [name, req.user.id]
  ).then(function(result) {
    res.json({ user: result.rows[0] });
  }).catch(function(err) {
    res.status(500).json({ error: { message: "Failed to update profile" } });
  });
});

module.exports = router;

Application Wiring

// app.js
var express = require("express");
var app = express();

app.use(express.json());

// Public routes
app.use("/auth", require("./routes/auth"));

// Protected routes
app.use("/api", require("./routes/protected"));

// Error handler
app.use(function(err, req, res, next) {
  console.error(err.stack);
  res.status(500).json({ error: { message: "Internal server error" } });
});

module.exports = app;

Password Security

bcrypt Configuration

var bcrypt = require("bcrypt");

// Cost factor: 12 takes ~250ms to hash (good balance of security and speed)
// Each increment doubles the computation time
// 10 = ~100ms, 12 = ~250ms, 14 = ~1s
var SALT_ROUNDS = 12;

function hashPassword(password) {
  return bcrypt.hash(password, SALT_ROUNDS);
}

function checkPassword(password, hash) {
  return bcrypt.compare(password, hash);
}

Password Requirements

function validatePassword(password) {
  var errors = [];

  if (password.length < 8) {
    errors.push("Must be at least 8 characters");
  }
  if (password.length > 128) {
    errors.push("Must be 128 characters or fewer");
  }

  return errors;
}

Keep password rules minimal. Length is the most important factor. Avoid complex rules (uppercase, special characters) that lead to predictable patterns like Password1!.

Token Security

Generating Strong Secrets

# Generate a 256-bit secret
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

Store secrets in environment variables:

JWT_ACCESS_SECRET=a1b2c3d4...  # 64+ hex characters
JWT_REFRESH_SECRET=e5f6g7h8... # Different from access secret

Token Expiration Strategy

Token Type Lifetime Storage Revocable
Access token 15 minutes Client memory No (short-lived)
Refresh token 7 days Database + client Yes

Short access token lifetimes limit exposure. If a token is compromised, it is valid for at most 15 minutes. Refresh tokens can be immediately revoked in the database.

Refresh Token Rotation

Every time a refresh token is used, revoke it and issue a new one. If an attacker steals a refresh token and the legitimate user also uses it, one of them will get an "invalid token" response — alerting you to the compromise.

// In the refresh route:
// 1. Verify the refresh token exists and is not revoked
// 2. Revoke the old refresh token
// 3. Issue a new refresh token
// 4. Issue a new access token

Cleanup Expired Tokens

// scripts/cleanup-tokens.js
var db = require("../db");

function cleanupExpiredTokens() {
  return db.query(
    "DELETE FROM refresh_tokens WHERE expires_at < NOW() OR revoked = true"
  ).then(function(result) {
    console.log("Cleaned up " + result.rowCount + " expired/revoked tokens");
  });
}

// Run daily
cleanupExpiredTokens()
  .then(function() { process.exit(0); })
  .catch(function(err) {
    console.error("Cleanup failed:", err);
    process.exit(1);
  });

Client-Side Token Management

Storing Tokens

// Browser client — store in memory for access token, localStorage for refresh

var accessToken = null;

function login(email, password) {
  return fetch("/auth/login", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: email, password: password })
  })
  .then(function(res) { return res.json(); })
  .then(function(data) {
    accessToken = data.accessToken;
    localStorage.setItem("refreshToken", data.refreshToken);
    return data;
  });
}

function authenticatedFetch(url, options) {
  options = options || {};
  options.headers = options.headers || {};
  options.headers["Authorization"] = "Bearer " + accessToken;

  return fetch(url, options).then(function(res) {
    if (res.status === 401) {
      // Token expired — try refresh
      return refreshAccessToken().then(function() {
        options.headers["Authorization"] = "Bearer " + accessToken;
        return fetch(url, options);
      });
    }
    return res;
  });
}

function refreshAccessToken() {
  var refreshToken = localStorage.getItem("refreshToken");

  return fetch("/auth/refresh", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refreshToken: refreshToken })
  })
  .then(function(res) {
    if (!res.ok) {
      // Refresh failed — redirect to login
      localStorage.removeItem("refreshToken");
      window.location.href = "/login";
      throw new Error("Session expired");
    }
    return res.json();
  })
  .then(function(data) {
    accessToken = data.accessToken;
    localStorage.setItem("refreshToken", data.refreshToken);
  });
}

Common Issues and Troubleshooting

"jwt must be provided" error

The token string is empty, undefined, or not a string:

Fix: Check that the Authorization header exists and follows the Bearer <token> format. Verify the client is sending the header with every request.

Token works in Postman but fails in the browser

CORS is blocking the Authorization header:

Fix: Configure CORS middleware to allow the Authorization header: cors({ origin: "http://localhost:3000", credentials: true }).

User role changes are not reflected in the token

JWTs contain a snapshot of user data from when the token was signed:

Fix: The role in the token stays the same until the token expires and a new one is issued. For immediate role changes, either invalidate all refresh tokens and force re-login, or check the database on sensitive operations.

bcrypt comparison always returns false

The stored hash was generated with a different library or encoding:

Fix: Verify that the hash starts with $2b$ or $2a$ (bcrypt format). Check that the password is not being trimmed or modified before comparison. Ensure the same bcrypt library is used for hashing and comparison.

Best Practices

  • Use separate secrets for access and refresh tokens. If one secret is compromised, the other token type remains secure.
  • Keep access tokens short-lived. 15 minutes is a good default. Shorter lifetimes mean less exposure if a token is stolen.
  • Store refresh tokens in the database. This allows immediate revocation. Access tokens are stateless (no database check), but refresh tokens must be verifiable and revocable.
  • Rotate refresh tokens on every use. Issue a new refresh token each time one is used. This detects token theft when both the attacker and user try to refresh.
  • Hash passwords with bcrypt at cost 12. bcrypt is specifically designed for password hashing. Cost 12 takes ~250ms — slow enough to resist brute force, fast enough for user experience.
  • Never log tokens. Access and refresh tokens in logs are a security risk. Redact them in request logging middleware.
  • Use HTTPS in production. Tokens sent over HTTP can be intercepted. Enforce HTTPS for all authentication endpoints.
  • Return the same error for wrong email and wrong password. "Invalid credentials" prevents attackers from determining which emails are registered.

References

Powered by Contentful