Learn how JSON Web Tokens work, how to generate and verify JWTs, and how to protect Express.js API routes using token-based authentication.
JWT authentication allows a server to issue a signed token after successfully verifying a user's credentials. The client can then send that token with later requests to access protected routes.
A typical signed JWT contains three parts separated by dots:
header.payload.signature
| Part | Purpose |
|---|---|
| Header | Contains token metadata, such as the signing algorithm. |
| Payload | Contains claims, such as a user ID and expiration time. |
| Signature | Helps verify that the token has not been modified and was signed with the expected key. |
Create a Node.js project if you do not already have one:
mkdir node-jwt-app
cd node-jwt-app
npm init -y
Install Express, JWT support, password hashing, and environment configuration:
npm install express jsonwebtoken bcryptjs dotenv
| Package | Purpose |
|---|---|
| express | Creates the server and API routes. |
| jsonwebtoken | Signs and verifies JWTs. |
| bcryptjs | Hashes passwords and checks submitted passwords. |
| dotenv | Loads environment variables from a local file. |
A secret key is used to sign and verify tokens when using a symmetric algorithm such as HS256.
Generate a strong random secret using Node.js:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Create a .env file:
JWT_SECRET=replace_with_your_generated_random_secret
PORT=5000
Replace the placeholder with the generated value. Keep the
.env file private and do not commit it to source control.
.env
node_modules/
The jwt.sign() method creates a signed token from
a payload and signing key.
require("dotenv").config();
const jwt = require("jsonwebtoken");
if (!process.env.JWT_SECRET) {
throw new Error("JWT_SECRET must be configured");
}
const token = jwt.sign(
{ userId: 101 },
process.env.JWT_SECRET,
{
algorithm: "HS256",
expiresIn: "1h"
}
);
console.log(token);
jwt.sign() creates a signed JWT.userId identifies the user in this example.JWT_SECRET is the signing secret.algorithm: "HS256" explicitly selects the signing algorithm.expiresIn: "1h" makes the token expire after one hour.
Use jwt.verify() to check the token's signature,
expiration, and other applicable registered claims.
require("dotenv").config();
const jwt = require("jsonwebtoken");
const token = jwt.sign(
{ userId: 101 },
process.env.JWT_SECRET,
{
algorithm: "HS256",
expiresIn: "1h"
}
);
try {
const decoded = jwt.verify(
token,
process.env.JWT_SECRET,
{
algorithms: ["HS256"]
}
);
console.log(decoded);
} catch (error) {
console.log("Invalid or expired token");
}
{
userId: 101,
iat: 1234567890,
exp: 1234571490
}
The iat and exp values represent
token issue and expiration times as Unix timestamps. The numbers
above are illustrative.
jwt.decode() alone to authorize requests.
Decoding does not verify the signature. Use jwt.verify()
before trusting token claims.
For this beginner example, we will store one practice account in memory. A real application should use a database and enforce unique accounts at the database level.
Create users.js:
const bcrypt = require("bcryptjs");
const users = [
{
id: 1,
name: "Rahul Kumar",
email: "rahul@example.com",
passwordHash: bcrypt.hashSync("ExamplePass123", 12)
}
];
module.exports = users;
Create a file named server.js. It includes a login
route, JWT creation, authentication middleware, and a protected profile route.
require("dotenv").config();
const express = require("express");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");
const users = require("./users");
const app = express();
app.use(express.json());
if (!process.env.JWT_SECRET) {
throw new Error("JWT_SECRET must be configured");
}
// Login route
app.post("/login", async (req, res) => {
try {
const { email, password } = req.body;
if (
typeof email !== "string" ||
typeof password !== "string"
) {
return res.status(400).json({
message: "Email and password are required"
});
}
const normalizedEmail = email.trim().toLowerCase();
const user = users.find(
item => item.email === normalizedEmail
);
if (!user) {
return res.status(401).json({
message: "Invalid email or password"
});
}
const passwordMatches = await bcrypt.compare(
password,
user.passwordHash
);
if (!passwordMatches) {
return res.status(401).json({
message: "Invalid email or password"
});
}
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{
algorithm: "HS256",
expiresIn: "1h"
}
);
res.json({
message: "Login successful",
token: token,
user: {
id: user.id,
name: user.name,
email: user.email
}
});
} catch (error) {
console.error("Login error:", error.message);
res.status(500).json({
message: "Unable to log in"
});
}
});
// JWT authentication middleware
function authenticateToken(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({
message: "Authentication token required"
});
}
const token = authHeader.slice(7).trim();
if (!token) {
return res.status(401).json({
message: "Authentication token required"
});
}
try {
const decoded = jwt.verify(
token,
process.env.JWT_SECRET,
{
algorithms: ["HS256"]
}
);
req.userId = decoded.userId;
next();
} catch (error) {
return res.status(401).json({
message: "Invalid or expired token"
});
}
}
// Protected profile route
app.get("/profile", authenticateToken, (req, res) => {
const user = users.find(
item => item.id === req.userId
);
if (!user) {
return res.status(401).json({
message: "User no longer exists"
});
}
res.json({
id: user.id,
name: user.name,
email: user.email
});
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Start the server from your project folder:
node server.js
Expected output:
Server running on port 5000
Send a POST request to:
http://localhost:5000/login
Choose Body → raw → JSON and enter:
{
"email": "rahul@example.com",
"password": "ExamplePass123"
}
{
"message": "Login successful",
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": 1,
"name": "Rahul Kumar",
"email": "rahul@example.com"
}
}
The token shown above is illustrative. Your server generates a different token when you log in.
After logging in, copy the returned token and send a GET request to:
http://localhost:5000/profile
In Postman:
{
"id": 1,
"name": "Rahul Kumar",
"email": "rahul@example.com"
}
Without a valid token, the protected endpoint returns
401 Unauthorized.
A frontend application can send a token in the Authorization header.
const token = "YOUR_TOKEN_FROM_LOGIN";
fetch("http://localhost:5000/profile", {
method: "GET",
headers: {
"Authorization": "Bearer " + token
}
})
.then(response => {
if (!response.ok) {
throw new Error("Request failed: " + response.status);
}
return response.json();
})
.then(data => console.log(data))
.catch(error => console.error(error));
| JWT Authentication | Session Authentication |
|---|---|
| Often sends a signed token with each request. | Usually sends a session ID in a cookie. |
| Token claims can be verified using a signing key. | The server looks up the session in a session store. |
| Revocation may require additional design, such as short expiry or a denylist. | The server can invalidate a session by destroying it. |
| Useful for some distributed API architectures. | Useful for traditional web applications and server-managed login state. |
| Error | Possible Solution |
|---|---|
| Token required | Send the token using the Authorization header. |
| Invalid token | Check that the token is complete and signed with the expected secret. |
| Token expired | Authenticate again to obtain a fresh token. |
| JWT_SECRET missing | Configure the secret in the environment. |
| Cannot find module | Run npm install jsonwebtoken. |
| 401 Unauthorized | Verify the login credentials and send a valid token. |
Create a Node.js API that uses JWT authentication.
Bonus Task: Add a role claim and restrict an admin-only route. Remember that authorization must be checked on the server.
Select the correct answer for each question.