How to build a login system where the server doesn't need to remember anything about you.
Here's the problem with building any application that has user accounts: how does the server know that the person making a request is actually who they say they are?
You log in once. But then you visit 10 different pages, make 15 API calls, upload a file. Each of those is a separate HTTP request, and HTTP is stateless — every request is completely independent. The server has no memory of the previous one. So how does it know you're still logged in?
The answer is authentication tokens — and the most popular one is JWT (JSON Web Token). You log in, the server gives you a token, and you show that token with every request. No session storage. No database lookup. The token itself proves who you are.
Let me show you how this works from scratch in Node.js. This was one of the most practical things I've built in the ChaiCode Web Dev Cohort 2026.
What Does Authentication Mean?
Authentication answers one question: "Are you who you claim to be?"
Think of it like checking into a hotel:
- You arrive and show your ID at the front desk → the hotel verifies your identity
- The hotel gives you a room key card → proof that you've been verified
- Every time you enter your room, you swipe the key card → the door trusts the card without calling the front desk again
In web terms:
Step 1: User sends credentials (email + password) → Login
Step 2: Server verifies and sends back a token → "Here's your key card"
Step 3: User sends token with every request → "Here's my key card"
Step 4: Server checks the token → "Valid card, come in"
The token is the key card. The server is the door lock. And JWT is the specific type of key card we're using.
What Is JWT?
JWT (JSON Web Token) is a compact, self-contained token that carries information about a user. The key word is self-contained — the token itself holds the user data. The server doesn't need to store anything or look anything up.
Why "Self-Contained"?
With a traditional session, the server gives you a random ID and stores your data:
Session approach:
Token: "abc123" (meaningless — server has to look it up)
Server: "Let me check my database... abc123 = Pratham, developer"
JWT approach:
Token: "eyJhbGci..." (contains user data inside it!)
Server: "Let me verify the signature... valid!
The token says id=1, name=Pratham, role=developer"
The server creates the JWT, sends it to the client, and forgets about it. When the client sends it back, the server can verify it using only the secret key — no storage, no database, no session state.
Structure of a JWT
Every JWT is a single string made up of three parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwibmFtZSI6IlByYXRoYW0iLCJyb2xlIjoiZGV2ZWxvcGVyIiwiaWF0IjoxNzE1MzQ4MDAwLCJleHAiOjE3MTU0MzQ0MDB9.kX9_7TgL2Rm5pVa0Q1sWnJkGYM7v3BcFhR9Xp2yNmW4
└─────────── Header ───────────┘└──────────────────────────── Payload ────────────────────────────┘└───────── Signature ─────────┘
Part 1: Header
The header tells the server how the token was signed.
{
"alg": "HS256",
"typ": "JWT"
}
-
alg— the algorithm used to create the signature (HS256 is common) -
typ— the token type (always "JWT")
This JSON is Base64-encoded (not encrypted — anyone can decode it).
Part 2: Payload
The payload contains the actual data — called claims. This is where user information lives.
{
"id": 1,
"name": "Pratham",
"role": "developer",
"iat": 1715348000,
"exp": 1715434400
}
-
id,name,role— your custom data (whatever you put in) -
iat— "issued at" timestamp (when the token was created) -
exp— "expires at" timestamp (when the token becomes invalid)
This is also Base64-encoded — readable by anyone. Never put passwords or sensitive data in a JWT payload.
Part 3: Signature
The signature is what makes JWT secure. It's created by combining the header, payload, and a secret key that only the server knows.
Signature = HMAC-SHA256(
base64(header) + "." + base64(payload),
"my-secret-key"
)
The signature ensures two things:
- The token hasn't been tampered with — if anyone changes the payload, the signature won't match
- The token was created by your server — only your server knows the secret key
JWT Security Model:
┌────────┐ ┌─────────┐ ┌───────────┐
│ Header │ │ Payload │ │ Signature │
│ │ │ │ │ │
│ Anyone │ │ Anyone │ │ Only the │
│ can │ │ can │ │ server │
│ READ │ │ READ │ │ can │
│ this │ │ this │ │ CREATE or │
│ │ │ │ │ VERIFY │
│ │ │ │ │ this │
└────────┘ └─────────┘ └───────────┘
The data is PUBLIC. The verification is PRIVATE.
Login Flow Using JWT
Here's the complete authentication flow from login to protected access:
JWT Login Authentication Flow
┌──────────┐ ┌──────────────┐
│ Client │ │ Server │
└────┬─────┘ └──────┬───────┘
│ │
│ 1. POST /login │
│ { email: "pratham@dev.in", │
│ password: "mypassword" } │
│ ──────────────────────────────────────→ │
│ │
│ 2. Find user in DB
│ 3. Compare password hash
│ 4. Password matches? ✅
│ 5. Create JWT:
│ jwt.sign({
│ id: 1,
│ name: "Pratham",
│ role: "developer"
│ }, SECRET, { expiresIn: "7d" })
│ │
│ 6. Response: │
│ { token: "eyJhbGci..." } │
│ ←────────────────────────────────────── │
│ │
│ 7. Client stores the token │
│ (localStorage, cookie, or memory) │
│ │
│ 8. GET /api/profile │
│ Headers: │
│ Authorization: Bearer eyJhbGci... │
│ ──────────────────────────────────────→ │
│ │
│ 9. Extract token from header
│ 10. jwt.verify(token, SECRET)
│ 11. Valid? ✅ → extract user
│ │
│ 12. Response: │
│ { user: { id: 1, name: "Pratham" } } │
│ ←────────────────────────────────────── │
│ │
└────┴─────┘ └──────┴───────┘
Step-by-Step Implementation
1. Project Setup
mkdir jwt-auth-demo
cd jwt-auth-demo
npm init -y
npm install express jsonwebtoken bcryptjs
-
express— web framework -
jsonwebtoken— create and verify JWTs -
bcryptjs— hash passwords securely
2. Complete Auth Server
const express = require("express");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");
const app = express();
app.use(express.json());
// Secret key — in production, use environment variables!
const JWT_SECRET = "my-super-secret-key-change-this-in-production";
// Simulated database
const users = [];
// ─── REGISTER ───
app.post("/register", async (req, res) => {
const { name, email, password } = req.body;
// Validation
if (!name || !email || !password) {
return res.status(400).json({ error: "All fields are required" });
}
// Check if user exists
if (users.find((u) => u.email === email)) {
return res.status(400).json({ error: "Email already registered" });
}
// Hash the password (NEVER store plain text passwords)
const hashedPassword = await bcrypt.hash(password, 10);
// Save user
const newUser = {
id: users.length + 1,
name,
email,
password: hashedPassword,
};
users.push(newUser);
res.status(201).json({
message: "User registered successfully!",
user: { id: newUser.id, name: newUser.name, email: newUser.email },
});
});
// ─── LOGIN ───
app.post("/login", async (req, res) => {
const { email, password } = req.body;
// Find user
const user = users.find((u) => u.email === email);
if (!user) {
return res.status(401).json({ error: "Invalid email or password" });
}
// Compare password with hash
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).json({ error: "Invalid email or password" });
}
// Create JWT
const token = jwt.sign(
{ id: user.id, name: user.name, email: user.email },
JWT_SECRET,
{ expiresIn: "7d" },
);
res.json({
message: "Login successful!",
token,
});
});
app.listen(3000, () => {
console.log("Auth server running on http://localhost:3000");
});
Sending Token with Requests
Once the client has a token, it sends it with every request that needs authentication. The standard way is via the Authorization header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
From a Frontend (React, Vanilla JS)
// After login, store the token
const loginResponse = await fetch("/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "pratham@dev.in", password: "mypassword" }),
});
const { token } = await loginResponse.json();
// Use the token for protected requests
const profileResponse = await fetch("/api/profile", {
headers: {
Authorization: `Bearer ${token}`,
},
});
const profile = await profileResponse.json();
console.log(profile); // { user: { id: 1, name: "Pratham", ... } }
From curl (Terminal)
# Login and get token
curl -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email": "pratham@dev.in", "password": "mypassword"}'
# Use token for protected route
curl http://localhost:3000/api/profile \
-H "Authorization: Bearer eyJhbGci..."
Where to Store the Token on the Client
| Storage | Pros | Cons |
|---|---|---|
localStorage |
Easy to use, persists across tabs | Vulnerable to XSS attacks |
httpOnly cookie |
Can't be accessed by JS (XSS-safe) | Vulnerable to CSRF (mitigated with sameSite) |
| Memory (variable) | Most secure, gone on refresh | Lost when user refreshes the page |
For learning: localStorage is fine. For production: httpOnly cookies are recommended.
Protecting Routes Using Tokens
This is where it all comes together. You create a middleware that checks for a valid JWT before allowing access to protected routes.
Auth Middleware
function authenticate(req, res, next) {
// 1. Get the Authorization header
const authHeader = req.headers.authorization;
// 2. Check if it exists and starts with "Bearer "
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "Access denied. No token provided." });
}
// 3. Extract the token (remove "Bearer ")
const token = authHeader.split(" ")[1];
// 4. Verify the token
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded; // Attach user data to the request
next(); // Token valid — proceed to route handler
} catch (error) {
if (error.name === "TokenExpiredError") {
return res.status(401).json({ error: "Token expired. Please log in again." });
}
return res.status(403).json({ error: "Invalid token." });
}
}
Token Validation Lifecycle
Request arrives with: Authorization: Bearer eyJhbGci...
│
↓
┌──────────────────────────────────────────────┐
│ AUTH MIDDLEWARE │
│ │
│ 1. Header present? │
│ NO → 401 "No token provided" │
│ YES ↓ │
│ │
│ 2. Starts with "Bearer "? │
│ NO → 401 "No token provided" │
│ YES ↓ │
│ │
│ 3. Extract token after "Bearer " │
│ │
│ 4. jwt.verify(token, SECRET) │
│ EXPIRED → 401 "Token expired" │
│ INVALID → 403 "Invalid token" │
│ VALID ↓ │
│ │
│ 5. Attach decoded payload to req.user │
│ │
│ 6. Call next() → proceed to route handler │
│ │
└──────────────────────────────────────────────┘
│
↓
Route handler runs with req.user available
Using the Middleware
// Public routes — anyone can access
app.post("/register", (req, res) => { /* ... */ });
app.post("/login", (req, res) => { /* ... */ });
// Protected routes — token required
app.get("/api/profile", authenticate, (req, res) => {
res.json({ user: req.user });
});
app.get("/api/dashboard", authenticate, (req, res) => {
res.json({
message: `Welcome to your dashboard, ${req.user.name}!`,
userId: req.user.id,
});
});
app.get("/api/settings", authenticate, (req, res) => {
res.json({
user: req.user.name,
settings: { theme: "dark", notifications: true },
});
});
Every route with authenticate as a second argument requires a valid JWT. Without it, the user gets a 401 or 403 error. With it, req.user contains the decoded token data.
Role-Based Access Control
Once you have JWT auth working, adding role-based access is just one more middleware:
function authorize(...allowedRoles) {
return (req, res, next) => {
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
error: "Forbidden — you don't have permission to access this resource",
});
}
next();
};
}
// Only admins
app.get("/api/admin/users", authenticate, authorize("admin"), (req, res) => {
res.json({ users: users.map((u) => ({ id: u.id, name: u.name, email: u.email })) });
});
// Admins and moderators
app.delete("/api/posts/:id", authenticate, authorize("admin", "moderator"), (req, res) => {
res.json({ message: `Post ${req.params.id} deleted` });
});
// Any authenticated user
app.get("/api/profile", authenticate, (req, res) => {
res.json({ user: req.user });
});
The middleware chain: authenticate → authorize → route handler. Each layer adds a check.
Complete Working Example
Here's the full server with registration, login, and protected routes:
const express = require("express");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");
const app = express();
app.use(express.json());
const JWT_SECRET = "my-secret-key";
const users = [];
// Auth middleware
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "No token provided" });
}
try {
const token = authHeader.split(" ")[1];
req.user = jwt.verify(token, JWT_SECRET);
next();
} catch (err) {
res.status(403).json({ error: "Invalid or expired token" });
}
}
// Register
app.post("/register", async (req, res) => {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ error: "All fields required" });
}
if (users.find((u) => u.email === email)) {
return res.status(400).json({ error: "Email already exists" });
}
const hashed = await bcrypt.hash(password, 10);
const user = { id: users.length + 1, name, email, password: hashed, role: "member" };
users.push(user);
res.status(201).json({ message: "Registered!", user: { id: user.id, name, email } });
});
// Login
app.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = users.find((u) => u.email === email);
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: "Invalid credentials" });
}
const token = jwt.sign(
{ id: user.id, name: user.name, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: "24h" },
);
res.json({ message: "Login successful!", token });
});
// Protected routes
app.get("/api/profile", authenticate, (req, res) => {
res.json({ user: req.user });
});
app.get("/api/dashboard", authenticate, (req, res) => {
res.json({ message: `Welcome, ${req.user.name}!`, role: req.user.role });
});
// Public route
app.get("/", (req, res) => {
res.json({ message: "Welcome! Please register or login." });
});
app.listen(3000, () => console.log("Server running on http://localhost:3000"));
Testing the Full Flow
# 1. Register
curl -X POST http://localhost:3000/register \
-H "Content-Type: application/json" \
-d '{"name":"Pratham","email":"pratham@dev.in","password":"pass123"}'
# 2. Login (copy the token from the response)
curl -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email":"pratham@dev.in","password":"pass123"}'
# 3. Access protected route (paste your token)
curl http://localhost:3000/api/profile \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
# 4. Try without token (should get 401)
curl http://localhost:3000/api/profile
Let's Practice: Hands-On Assignment
Part 1: Build the Auth System
Create a server with:
-
POST /register— hash password with bcrypt, save user -
POST /login— verify credentials, return JWT -
GET /api/profile— protected, return user data from token
Part 2: Add Token Expiry Handling
Set tokens to expire in 1 minute ({ expiresIn: "1m" }). Login, use the token, wait 2 minutes, try again — observe the "Token expired" error. Then change it to a reasonable time like 24h.
Part 3: Add Role-Based Access
Add a role field to users ("member" or "admin"). Create an authorize middleware that checks roles. Make a route like GET /api/admin that only admins can access.
Part 4: Decode a JWT
Go to jwt.io, paste your token, and see the decoded header and payload. Notice that the payload is fully readable — this is why you never put passwords in a JWT.
Key Takeaways
- Authentication verifies identity. JWT is a token-based approach where the server creates a signed token at login and the client sends it with every request.
- JWT has three parts: Header (algorithm), Payload (user data — readable by anyone), and Signature (proof of authenticity — only the server can create/verify).
- The login flow: Client sends credentials → Server validates → Server creates JWT → Client stores token → Client sends token with every protected request.
-
Protect routes with middleware that extracts the token from the Authorization header, verifies it with
jwt.verify(), and attaches the decoded user toreq.user. - JWTs are stateless — the server stores nothing. This makes them perfect for APIs and microservices, but means tokens can't be truly revoked before expiry.
Wrapping Up
JWT authentication is the standard for modern APIs. Once you build this login → token → protected route flow once, every future project follows the same pattern. Register, login, get token, send token, verify token — that's the entire system.
I'm learning all of this through the ChaiCode Web Dev Cohort 2026 under Hitesh Chaudhary and Piyush Garg. Building a real JWT auth system was the first time I felt like I was writing production-level backend code. It's the foundation for everything — user accounts, protected dashboards, role-based access, API security.
Connect with me on LinkedIn or visit PrathamDEV.in. More articles on the way.
Happy coding! 🚀
Written by Pratham Bhardwaj | Web Dev Cohort 2026, ChaiCode
Top comments (0)