
JWT lets your server verify who a user is without storing anything in a database. This post walks through what a JWT is, how the login flow works, and how to protect routes using tokens in Node.js.
Every application that has users needs to answer one question on every request: who is asking, and are they allowed? That's authentication and authorization in a sentence. The simplest version is a username and password check on every request, but that's both wasteful and insecure. You'd be sending credentials over the wire constantly. JWT solves this by exchanging credentials once, getting a signed token back, and using that token for every request afterward.
JWT stands for JSON Web Token. After a successful login, the server creates a token that contains information about the user, signs it with a secret key, and sends it to the client. On every subsequent request, the client sends that token back. The server verifies the signature and knows who you are without touching a database.
The key insight: the server doesn't store the token. It only needs the secret key to verify that a token it receives is genuinely one it created. That's what makes JWT stateless.
A JWT looks like this:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjQyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Three parts, separated by dots:
┌────────────────────────────────────────────────────┐ │ JWT Token │ │ │ │ eyJhbGciOiJIUzI1NiJ9 │ │ ────────────────────── │ │ Header: algorithm used to sign the token │ │ │ │ eyJ1c2VySWQiOjQyfQ │ │ ──────────────────── │ │ Payload: your data (userId, role, expiry) │ │ │ │ SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c │ │ ───────────────────────────────────────────── │ │ Signature: proves the token wasn't tampered with │ └────────────────────────────────────────────────────┘
The header and payload are base64-encoded, not encrypted. Anyone can decode them. The signature is what guarantees authenticity: without the server's secret key, you can't forge a valid one. So you can read a JWT, but you can't fake one.
javascript// Decoded payload example { "userId": 42, "email": "atharv@example.com", "role": "admin", "iat": 1714000000, // issued at (Unix timestamp) "exp": 1714086400 // expires at (24 hours later) }
Don't put sensitive data like passwords in the payload. It's readable.
Client Server │ │ │ POST /login │ │ { email, password } │ │─────────────────────────────►│ │ │ 1. Look up user in DB │ │ 2. Compare password hash │ │ 3. If valid, create JWT │ │ sign with secret key │ { token: "eyJ..." } │ │◄─────────────────────────────│ │ │ │ GET /dashboard │ │ Authorization: Bearer eyJ.. │ │─────────────────────────────►│ │ │ 4. Verify token signature │ │ 5. Decode payload │ │ 6. No DB lookup needed │ 200 OK + protected data │ │◄─────────────────────────────│
Install the jsonwebtoken package:
bashnpm install jsonwebtoken bcrypt
Create your login route:
javascriptLoading syntax highlighter...
jwt.sign() takes three arguments: the payload (what to encode), the secret (to sign with), and options like expiresIn. The token returned is the full JWT string.
Always return the same error message for both "user not found" and "wrong password." Separate messages let attackers enumerate valid email addresses.
The standard approach is the Authorization header with the Bearer scheme:
GET /dashboard HTTP/1.1 Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
On the client side in JavaScript:
javascriptfetch("/dashboard", { headers: { Authorization: `Bearer ${token}`, }, });
The real power is in middleware. Write one function that verifies the token and attach it to any route that needs protection:
javascriptLoading syntax highlighter...
Incoming request with token │ ▼ ┌────────────────────────┐ │ authenticate() │ │ middleware │ │ │ │ token present? ──No──► 401 Unauthorized │ │ │ │ Yes │ │ │ │ │ jwt.verify() ──Fail──► 401 Invalid token │ │ │ │ Success │ │ │ │ │ req.user = decoded │ │ next() │ └──────────┬─────────────┘ │ ▼ Route handler runs (req.user available)
jwt.verify() throws if the token is expired, malformed, or signed with a different secret. The try/catch catches all of those and returns a consistent 401.
Short expiry (15 minutes to 1 hour) limits damage if a token is stolen. But you don't want users logging in every hour. The standard pattern is two tokens: a short-lived access token for API requests, and a long-lived refresh token stored in an HttpOnly cookie to get new access tokens silently.
For a first implementation, start with a 1-hour access token. Add refresh tokens when you need them.
jsonwebtoken: jwt.sign() creates a token, jwt.verify() validates one.JWT authentication is one of those patterns that feels like a lot when you first see it, but breaks down into about three steps: sign on login, send with every request, verify in middleware. Once that flow is clear, the rest is just details.
Related posts based on tags, category, and projects
Middleware is code that runs between a request arriving and a response being sent. In Express, every middleware function in the chain gets a chance to inspect, modify, or stop a request. This post covers what middleware is, how `next()` controls the flow, and where it gets used in real applications.
REST is a set of conventions for designing APIs that are predictable, consistent, and easy to work with. This post covers what REST means, how it maps to HTTP, and how to build a clean resource-based API in Express.
File uploads arrive at your server in a format called multipart/form-data that Express can't parse on its own. Multer is the middleware that bridges that gap. This post covers how multer works, how to handle single and multiple files, and how to configure storage.
Handling file uploads in Express involves two things: storing the file somewhere on disk and making it accessible via a URL. This post covers both, using multer for uploads and Express's static file serving for access.
const express = require("express");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");
const app = express();
app.use(express.json());
const JWT_SECRET = process.env.JWT_SECRET; // never hardcode this
// Simulated user (in real apps, this comes from your database)
const storedUser = {
id: 1,
email: "atharv@example.com",
passwordHash: "$2b$10$...", // bcrypt hash of "password123"
};
app.post("/login", async (req, res) => {
const { email, password } = req.body;
if (email !== storedUser.email) {
return res.status(401).json({ error: "Invalid credentials" });
}
const isValid = await bcrypt.compare(password, storedUser.passwordHash);
if (!isValid) {
return res.status(401).json({ error: "Invalid credentials" });
}
const token = jwt.sign(
{ userId: storedUser.id, email: storedUser.email },
JWT_SECRET,
{ expiresIn: "1h" },
);
res.json({ token });
});function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "No token provided" });
}
const token = authHeader.split(" ")[1];
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded; // attach user info to the request
next(); // pass control to the next handler
} catch (err) {
return res.status(401).json({ error: "Invalid or expired token" });
}
}
// Protected route
app.get("/dashboard", authenticate, (req, res) => {
res.json({ message: `Welcome, ${req.user.email}` });
});
// Another protected route
app.get("/profile", authenticate, (req, res) => {
res.json({ userId: req.user.userId });
});