JWT Authentication in Node.js Explained Simply
You already know what authentication is on the surface. You log in, you get access, you log out. Simple.
But the moment you sit down to actually build it — you realize the question is not just "did this user enter the right password." The real question is: how does the server remember that on the next request?
HTTP does not remember anything. Every request starts fresh. You hit an endpoint, get a response, and the server immediately forgets you existed. So how do protected routes work? How does your profile page know who you are? How does the server let some requests through and block others?
That is the problem authentication solves. And JWT is one of the cleanest ways to solve it.
What is Authentication?
Authentication is proof of identity. You are telling the server — "I am this person, and here is the evidence."
The evidence is usually a password. You send your email and password, the server checks them, and if they match — you are authenticated. The challenge is what happens after that.
The server needs a way to recognize you on the next request without making you send your password every single time. It needs to issue you something — a pass, a badge, a token — that you can show on every subsequent request. Something that says "I already proved who I am."
That token is exactly what JWT is.
What is JWT?
JWT stands for JSON Web Token. It is a string the server generates after you successfully log in and hands back to you. From that point, every request you make to the server includes that token. The server looks at the token, verifies it, and knows who you are — without looking anything up in a database.
This is what makes JWT stateless. The server stores nothing. The token itself carries the information.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MDAwMDAwMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
That wall of text is a JWT. It looks scrambled, but it has a clear structure. Three parts, separated by dots.
Structure of a JWT
Every JWT is made of three pieces — a header, a payload, and a signature — each base64 encoded and joined with dots.
header.payload.signature
Header
The header describes the token itself. What type of token is this, and which algorithm was used to sign it.
{
"alg": "HS256",
"typ": "JWT"
}
HS256 means HMAC with SHA-256 — the algorithm used to create the signature. This gets base64 encoded and becomes the first part of your token.
Payload
The payload is where your actual data lives. These pieces of data are called claims — things you are claiming about the user.
{
"userId": 42,
"role": "admin",
"iat": 1700000000,
"exp": 1700003600
}
iat is issued at. exp is expiry time — a Unix timestamp after which this token is no longer valid. The payload gets base64 encoded and becomes the second part.
Important detail — the payload is not encrypted. Anyone can decode it and read it. Never put passwords or sensitive data in here. The security comes from the signature, not from hiding the contents.
Signature
The signature is what makes the whole thing trustworthy. The server takes the encoded header, the encoded payload, combines them, and signs that with a secret key only the server knows.
HMAC-SHA256(
base64(header) + "." + base64(payload),
SECRET_KEY
)
If anyone tampers with the payload — changes the userId, bumps up the expiry — the signature will no longer match. The server will reject it. You cannot fake a valid JWT without knowing the secret key. That is the entire security model.
The Login Flow
Here is exactly how it works end to end, from the moment a user enters their credentials to the moment they access a protected route.
1. User sends email + password to POST /login
2. Server checks credentials against the database
3. If valid — server generates a JWT signed with its secret key
4. Server sends the JWT back to the client
5. Client stores the JWT (localStorage or a cookie)
6. Every future request includes the JWT
7. Server verifies the signature on each request
8. If valid — request goes through. If not — 401 Unauthorized.
The server never stores the token. It only stores the secret key used to sign it. That is what makes it stateless — the token carries the session, not the server.
Generating a Token — Server Side
On the server, after verifying credentials, you create and sign the token.
const jwt = require("jsonwebtoken");
const SECRET_KEY = "your_secret_key_here";
// After confirming the user's credentials are correct
function loginUser(user) {
const payload = {
userId: user.id,
role: user.role,
};
const token = jwt.sign(payload, SECRET_KEY, { expiresIn: "1h" });
return token;
}
jwt.sign() takes the payload, the secret, and options like expiresIn. It returns the token string. You send this back to the client in your response.
app.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = await findUserByEmail(email);
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ message: "Invalid credentials" });
}
const token = jwt.sign({ userId: user.id, role: user.role }, SECRET_KEY, {
expiresIn: "1h",
});
res.json({ token });
});
Sending the Token with Requests
The client receives the token and stores it. Then on every protected request, it sends the token in the Authorization header.
// Storing the token after login
const response = await fetch("/login", {
method: "POST",
body: JSON.stringify({ email, password }),
headers: { "Content-Type": "application/json" },
});
const { token } = await response.json();
localStorage.setItem("token", token);
// Sending the token on a protected request
const token = localStorage.getItem("token");
const profileResponse = await fetch("/profile", {
headers: {
Authorization: `Bearer ${token}`,
},
});
Bearer is just a convention — a prefix that tells the server this is a bearer token, meaning whoever holds it gets access. The server reads the Authorization header, strips the Bearer prefix, and gets the raw token.
Protecting Routes — Server Side
On the server, you write a middleware function that sits in front of any protected route. Its job is to check the token before the request goes any further.
function authenticate(req, res, next) {
const authHeader = req.headers["authorization"];
if (!authHeader) {
return res.status(401).json({ message: "No token provided" });
}
const token = authHeader.split(" ")[1]; // Remove "Bearer "
try {
const decoded = jwt.verify(token, SECRET_KEY);
req.user = decoded; // Attach user data to the request
next(); // Let the request continue
} catch (error) {
return res.status(401).json({ message: "Invalid or expired token" });
}
}
jwt.verify() does two things at once — it checks the signature and checks if the token has expired. If either fails, it throws an error and you return a 401. If it succeeds, you get back the decoded payload and you attach it to req.user so the route handler can use it.
Now you apply this middleware to any route that needs protection.
// Public route — no middleware
app.post("/login", loginHandler);
// Protected routes — middleware runs first
app.get("/profile", authenticate, (req, res) => {
res.json({ message: `Welcome, user ${req.user.userId}` });
});
app.get("/dashboard", authenticate, (req, res) => {
res.json({ data: "your private dashboard data" });
});
app.delete("/account", authenticate, (req, res) => {
// req.user is available here because authenticate ran first
deleteUser(req.user.userId);
res.json({ message: "Account deleted" });
});
Any request to /profile, /dashboard, or /account first goes through authenticate. If the token is missing, invalid, or expired — it stops right there. If the token is valid — the request passes through and the route handler runs with full knowledge of who the user is.
Why This Actually Matters
Session-based auth needs a database or a Redis store running. Every request triggers a lookup — find the session ID, retrieve the data, check if it is still valid. That works fine for a single server, but the moment you are running multiple servers or microservices, they all need access to the same session store. That is shared state, and shared state is complexity.
JWT eliminates that dependency. Any server with the secret key can verify any token completely independently. Your API server, your separate image upload service, your notification service — they can all authenticate the same user without talking to each other.
The tradeoff is that you give up instant revocation. Once a token is issued, it is valid until it expires. If a user logs out, you can delete the token from the client — but if someone already copied that token, it still works until expiry. The standard fix is short-lived tokens — 15 minutes to an hour — combined with refresh tokens for seamless re-authentication.
Short-lived tokens. Server keeps the secret. Client keeps the token. Every request carries proof of identity. The server verifies and moves on.
That is the whole system. Clean, stateless, and scalable by design.
