JWT Authentication in Node.js Explained Simply
Most applications have parts that anyone can see and parts that only certain people should access. A homepage is public. An account dashboard is not. A product listing is open to everyone. An order history belongs to one specific user. Enforcing that boundary, making sure the right people can reach the right things, is what authentication is about.
Authentication answers one question: who are you? Once the server knows the answer, it can decide what you are allowed to do.
The Problem Authentication Solves
HTTP is stateless. Every request a browser sends to a server arrives without any memory of previous requests. From the server's perspective, each request is brand new, sent by an unknown party.
This creates a practical problem. A user logs in on one request. On the very next request, the server has already forgotten them. Without some mechanism to carry identity across requests, every page load would require logging in again.
Authentication solves this by giving the user something to hold onto after logging in. Something they can present on every subsequent request to prove who they are. The server checks that proof, recognizes the user, and responds accordingly.
There are different ways to implement this. Sessions store identity on the server and give the user a reference key. JWT takes a different approach: it encodes identity directly into a token and gives that token to the user to carry around themselves.
What JWT Is
JWT stands for JSON Web Token. It is a compact, self-contained token that holds information about a user directly inside it. Instead of storing user data on the server and looking it up on every request, the server encodes that data into a token, signs it, and hands it to the client. The client stores the token and sends it back with every subsequent request. The server verifies the token and reads the user data out of it without touching a database.
This is stateless authentication. The server keeps no record of who is logged in. The token itself carries all the information the server needs.
The Structure of a JWT
A JWT is a string made of three parts separated by dots:
header.payload.signature
It looks something like this in practice:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjMiLCJlbWFpbCI6ImFsaWNlQGV4YW1wbGUuY29tIiwiaWF0IjoxNjE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Each part is Base64 encoded, which is why it looks like random characters. Decoded, each part has a clear purpose.
The Header
The header identifies what kind of token this is and which algorithm was used to sign it:
{
"alg": "HS256",
"typ": "JWT"
}
HS256 is the signing algorithm. It stands for HMAC SHA-256, a common and reliable choice. This tells the server which algorithm to use when verifying the signature.
The Payload
The payload contains the actual data, called claims, that the token is carrying:
{
"userId": "123",
"email": "alice@example.com",
"iat": 1616239022,
"exp": 1616325422
}
iat means issued at, a Unix timestamp recording when the token was created. exp is the expiration time, after which the token is no longer valid. The other fields, userId and email, are custom claims you define. Include whatever your application needs to identify and serve the user without hitting a database.
One important note: the payload is encoded, not encrypted. Anyone who gets hold of a JWT can decode it and read the payload. Never put sensitive information like passwords or payment details in a JWT payload.
The Signature
The signature is what makes the token trustworthy. It is created by combining the encoded header, the encoded payload, and a secret key that only the server knows, then running them through the signing algorithm:
HMAC-SHA256(
base64(header) + "." + base64(payload),
secretKey
)
When a token arrives at the server, the server recomputes this signature using the same secret key. If the recomputed signature matches the one in the token, the token is genuine and untampered. If they do not match, something was changed, and the token is rejected.
This is the key property of JWTs. Anyone can read the payload. But nobody can modify the payload and produce a valid signature without knowing the secret key. The signature is what makes self-contained tokens trustworthy.
The Login Flow Using JWT
The flow from login to authenticated request follows a clear sequence.
Step one: the user sends credentials
The user submits their email and password. The request arrives at a login route on the server.
app.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = await findUserByEmail(email);
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
const passwordMatch = await bcrypt.compare(password, user.passwordHash);
if (!passwordMatch) {
return res.status(401).json({ error: "Invalid credentials" });
}
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: "24h" }
);
res.json({ token });
});
The server finds the user, verifies the password, and if everything checks out, creates a JWT using the jsonwebtoken library. The token is signed with a secret key stored in an environment variable and set to expire in 24 hours. The token is sent back in the response.
Step two: the client stores the token
The client receives the token and stores it somewhere. Storing it in memory is the most secure option but does not survive page refreshes. Storing it in an HTTP-only cookie is a common and secure approach for web applications. Local storage works but is accessible to JavaScript, which introduces some risk.
Step three: the client sends the token with future requests
On every subsequent request that requires authentication, the client includes the token in the Authorization header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
The Bearer prefix is a convention that signals a token-based authentication scheme. The server reads the header and extracts the token.
Protecting Routes Using Tokens
The server-side protection lives in middleware. Middleware is a function that runs before the route handler, checks the token, and either passes the request through or rejects it.
function authenticateToken(req, res, next) {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
return res.status(401).json({ error: "Access denied. No token provided." });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
res.status(403).json({ error: "Invalid or expired token." });
}
}
This middleware does three things. It extracts the token from the Authorization header. It verifies the token using jwt.verify, which checks both the signature and whether the token has expired. If verification succeeds, it attaches the decoded payload to req.user so the route handler can access it. If verification fails for any reason, it responds with an error and the request goes no further.
Applying this middleware to a route is straightforward:
app.get("/dashboard", authenticateToken, (req, res) => {
res.json({
message: "Welcome to your dashboard",
user: req.user
});
});
app.get("/orders", authenticateToken, (req, res) => {
const userId = req.user.userId;
const orders = getOrdersByUser(userId);
res.json({ orders });
});
Any route that receives the middleware as a second argument is protected. Requests without a valid token never reach the route handler. Requests with a valid token arrive with req.user already populated with the decoded payload.
Public routes simply omit the middleware:
app.get("/products", (req, res) => {
res.json({ products: getAllProducts() });
});
No middleware, no restriction. Anyone can reach it.
Why This Works Without Storing Sessions
The elegance of JWT authentication is that the server stores nothing. No session table, no in-memory store, no database lookup on every request.
When a request arrives with a token, the server has everything it needs to authenticate the user: the token itself and the secret key it holds. It verifies the signature, checks the expiration, reads the payload, and knows who the user is. All of that happens in memory in milliseconds without a database round trip.
This is what makes JWT well suited to APIs that serve multiple clients, distributed systems running across multiple servers, or any situation where maintaining shared session state would be complicated. Any server that has the secret key can verify any token.
The tradeoff is control. Once a token is issued, it remains valid until it expires. If a user logs out or a token needs to be invalidated before expiry, you have to build that mechanism yourself, usually by maintaining a small blocklist of revoked tokens. It is the one area where stateless authentication requires some stateful infrastructure to handle edge cases cleanly.
Wrapping Up
JWT authentication gives you a complete, self-contained system for managing identity across stateless HTTP requests. The user logs in once, receives a signed token, and presents that token on every subsequent request. The server verifies the token, trusts what it says, and never needs to look anything up.
The three-part structure of a JWT, header, payload, and signature, is what makes this work. The header describes the token. The payload carries the data. The signature ensures neither was tampered with. Together they give the server everything it needs to authenticate a request in a single, compact string.
For Node.js APIs, this approach is clean, scalable, and easy to implement with the right libraries. Once you understand the flow, the code follows naturally.
Done and Dusted!
written by theadroitdev(Shivam Verma) :)
๐ โ https:/theadroitdev.com/
X โ https://x.com/theadroitdev
Github โ https://github.com/TheAdroitDev
ChaiCode Repo โ https://github.com/TheAdroitDev/ChaiCode-Cohort-2026


