JWT Authentication in Node.js Explained Simply

I like making things with code. This is where I share my projects and the bugs I ran into.
Some time back, an old school friend of mine got me invited to a formal VIP party in Mumbai. It was not exactly his family's party, it was a proper high-end business party, at the Taj hotel no less, but he really wanted me to come so we could finally meet after years, and he somehow managed to arrange an invitation for me. I was like, thik hai, ek party hi toh hai, kitna kya hoga.
He told me one thing clearly, keep your email handy. The invitation had come by email, and inside it were all sorts of details, a reference number, a personal id, a floor number, a key id, and a QR code. Mujhe laga itna sab kyun, it is just a party yaar, but okay, thik hai.
Then I reached the Taj. At the entrance, the guards asked for that email, and specifically the QR code in it. I showed it, they scanned and verified me, and only then they handed me a key, and that key had a unique token id on it. And bhai, that key was everything. With it I could access anything inside, washrooms, rooms, staff service, food, desserts, ice cream, drinks, matlab anything. I did not have to prove who I was again at any point, the key did all the talking. I walked in, found my friend, and he hugged me for a solid thirty seconds, that feeling I honestly cannot put into words.
Now here is the part that makes this the perfect story for today. At one point my friend had to step out for some urgent work, so I was just casually exploring the room, and somewhere I set my key down and walked out, and the door auto-locked behind me. And suddenly I was helpless, I could not access anything, not even a washroom. I asked the guards for a new key, and they flatly refused, that key is unique to you, we cannot just issue another. No amount of arguing worked, and my friend was not picking up. Eventually he came back, told his father, and finally a hotel senior manager used a master key to unlock the room and return mine, with one instruction, keep this with you always. Aur uske baad, trust me, I did.
That entire evening is exactly how JWT authentication works. You prove who you are once, you get a unique tamper-proof token, and after that you just present that token to access anything, no re-proving. And lose it, and you are locked out completely. Chalo, let us build this properly. Oh, and from this post onward, all our code is in TypeScript, which is what real backends actually use.
First, what authentication even means
Authentication is simply the server figuring out who you are. When a request comes in, the server needs to know, is this really Ayush. Proving your identity to the system, that is authentication. One quick clarification so scope is clear, what you are then allowed to do once the server knows you, like admin versus a normal user, is a separate thing called authorization, and that gets its own post later. Today is purely about proving who you are.
Back in the middleware and file posts, we faked this with a hardcoded check, x-api-key: secret123. That was a placeholder, like a party where everyone shares one common password, not exactly VIP. Real apps cannot work like that, every user is different, and the server must know exactly which user each request belongs to. That is the real problem JWT solves.
The real problem: HTTP forgets you
Here is the thing that makes authentication tricky. HTTP is stateless, meaning the server forgets you completely after every single request. Each request arrives like a total stranger, the server has no memory of the fact that you logged in a second ago.
So if you log in on one request, how does the very next request know it is still you? The guards at the Taj did not memorise my face, right. There are two ways to solve this. One, the server keeps a memory of everyone logged in, that is sessions, and I will cover that in the next post. Two, the client carries its own proof of identity on every request, a self-contained pass the server can instantly check, and that is JWT. My Taj key was exactly this, the checkpoints inside did not remember me, my key itself proved everything, every single time.
What a JWT actually is
JWT stands for JSON Web Token. It is a signed token that the server gives you after you log in, and which you then send back with every future request to prove who you are.
That is my Taj key with its unique token id. After the gate verified me once, the key became my proof for the whole evening. A JWT is that key, in digital form. The server hands it to you at login, and you flash it on every request after.
The two words that matter most are signed and self-contained. Self-contained means the token itself carries who you are inside it, so the server does not need to look you up anywhere. Signed means it is sealed in a way that cannot be faked or edited, exactly why the guards could not just issue a duplicate of my unique key. Let us open it up and see how.
The structure of a JWT: three parts
A JWT is one long string that looks a bit scary at first, but it is just three parts joined by dots.
xxxxx.yyyyy.zzzzz
header . payload . signature
The header is small metadata, mainly which algorithm was used to sign the token. You rarely touch it directly.
The payload is the actual data, the claims, the useful stuff. This is where the server puts who you are, like your user id and email, maybe your role. This is what my key "carried", the fact that I was a verified guest allowed to access everything.
The signature is the tamper-proof seal. The server takes the header and payload, and signs them using a secret key that only the server knows. If anyone changes even one character of the token, the signature no longer matches, and the server instantly rejects it. This is exactly why my Taj key could not be duplicated or faked, only the hotel could make a real one.
One very important thing to burn in right now, and beginners get this wrong all the time. The payload is only encoded, not encrypted. Anyone who has the token can read the payload, it is just base64, easily decoded. The signature stops people from changing it, not from reading it. So never, ever put secret things like passwords inside a JWT payload.
The login flow: getting your token
Here is how a user actually gets a JWT. It is the gate at the Taj, in steps.
The user sends their credentials, email and password, to a login route. The server checks them against what it has stored. If they are wrong, it rejects with a 401, no entry. If they are right, the server creates a JWT, stuffs the user's id and email into the payload, signs it with its secret, and sends that token back to the client. The client saves it. Done, you are "inside," and that token is now your key for every future request.
Sending the token with every request
Once the client has the token, it sends it along on every request to a protected route, inside a header called Authorization, in the form Bearer <token>.
Authorization: Bearer eyJhbGciOi...
That is me flashing my key at every door inside the Taj. Every request that wants access carries the token, and the server checks it each time.
Protecting routes with the token
On the server side, protecting a route is a job for middleware, the exact idea from the middleware post. This auth middleware sits before your handler, reads the token from the Authorization header, and verifies its signature using the secret. If the token is valid, it lets the request through and even attaches the user's info onto the request, so your handler knows who is asking. If the token is missing, fake, or expired, it stops the request with a 401.
That is the checkpoint inside the Taj. Valid key, come in. No key, like when I got locked out, sorry, you cannot access anything. The middleware is the guard, the token is the key.
Building it in Express with TypeScript
Now the real thing, in TypeScript. We use a library called jsonwebtoken to create and verify tokens. Install it, along with its types.
npm install express jsonwebtoken
npm install -D typescript tsx @types/express @types/jsonwebtoken @types/node
Here tsx just lets us run a TypeScript file directly, and the @types packages give us type safety for the libraries. Now the code.
import express, { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
const app = express();
app.use(express.json());
// in a real app, keep this in an environment variable, never in code
const SECRET = "my-super-secret-key";
// our "user", normally this would come from a database
const user = { id: 1, email: "ayush@example.com", password: "1234" };
// the shape of what we store inside the token
interface TokenPayload {
userId: number;
email: string;
}
// a request that may carry a logged-in user
interface AuthRequest extends Request {
user?: TokenPayload;
}
// LOGIN: check credentials, then hand back a signed token
app.post("/login", (req: Request, res: Response) => {
const { email, password } = req.body;
if (email !== user.email || password !== user.password) {
return res.status(401).json({ error: "Wrong email or password" });
}
const payload: TokenPayload = { userId: user.id, email: user.email };
const token = jwt.sign(payload, SECRET, { expiresIn: "1h" });
res.json({ token });
});
// MIDDLEWARE: the guard that checks the key
function auth(req: AuthRequest, res: Response, next: NextFunction) {
const header = req.headers.authorization; // "Bearer <token>"
const token = header?.split(" ")[1];
if (!token) {
return res.status(401).json({ error: "No token, please log in" });
}
try {
const decoded = jwt.verify(token, SECRET) as TokenPayload;
req.user = decoded; // attach who they are
next();
} catch {
return res.status(401).json({ error: "Invalid or expired token" });
}
}
// PROTECTED route: only reachable with a valid token
app.get("/profile", auth, (req: AuthRequest, res: Response) => {
res.json({ message: `Welcome, ${req.user?.email}`, userId: req.user?.userId });
});
app.listen(3000, () => console.log("Server on http://localhost:3000"));