Authentication with JWT: Stateless Security for Modern Applications
Every application that stores user data needs to answer a fundamental question: "How do we know who you are?" Authentication is the process of verifying identity, and JSON Web Tokens (JWT) have become the dominant standard for implementing stateless authentication in modern web applications. This guide explains why authentication matters, how JWT works without deep cryptography, and how to implement a complete token-based login flow.
What Authentication Means
The Identity Problem
When you visit a website, the server receives an HTTP request. That request contains your IP address, browser type, and the page you want — but it does not contain your identity. HTTP is stateless: each request is independent, and the server forgets everything about you as soon as it sends a response.
Imagine a bank where every customer interaction starts with: "Who are you, and what is your account number?" You would have to present your ID and account card for every single transaction — checking balance, withdrawing cash, depositing a check. That is how HTTP works by default.
Authentication solves this by answering: "Is this person really who they claim to be?"
Why Authentication Is Required
Without authentication, applications cannot:
| Capability | Why It Needs Authentication |
|---|---|
| Personalized content | Show Alice her dashboard, not Bob's |
| Data protection | Prevent strangers from reading private messages |
| Authorization | Ensure only admins can delete accounts |
| Audit trails | Track who performed which action |
| Session continuity | Keep users logged in across page loads |
Authentication vs Authorization
These terms are often confused but solve different problems:
| Concept | Question It Answers | Example |
|---|---|---|
| Authentication | "Who are you?" | Verifying your password at login |
| Authorization | "What are you allowed to do?" | Checking if you can access the admin panel |
You must authenticate (prove identity) before you can be authorized (granted permissions).
The Session Problem: Stateful Authentication
Traditional Session-Based Authentication
Before JWT, most applications used session-based authentication:
1. User submits username + password
2. Server validates credentials
3. Server creates a session record in memory or database
4. Server sends a session ID cookie to the browser
5. Browser sends the cookie with every subsequent request
6. Server looks up the session ID in its storage to identify the user
The problem: The server must store session data somewhere — in memory, Redis, or a database. Every request requires a database lookup to find who owns that session ID.
Drawbacks:
- Storage overhead: Millions of logged-in users = millions of session records
- Scaling complexity: Session storage must be shared across multiple servers
- Sticky sessions: Load balancers must route users to the same server holding their session
- Cleanup: Expired sessions must be purged regularly
This is stateful authentication: the server maintains state (session data) for every active user.
Stateless Authentication: The JWT Solution
What "Stateless" Means
JWT enables stateless authentication: the server does not store any session data. Instead, all the information needed to verify a user is contained within the token itself. The server only needs to validate the token's integrity — no database lookups required.
Think of it like a tamper-evident concert wristband:
- At entry, the venue checks your ticket and gives you a wristband (JWT)
- The wristband contains your seat section and access level (payload)
- It has a special seal that proves it is genuine (signature)
- At every checkpoint, staff just inspect the wristband — they do not look up your name in a database
- If the seal is broken, the wristband is rejected immediately
What JWT Is
JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact, self-contained way to transmit information between parties as a JSON object. The information is digitally signed, making it verifiable and trustworthy.
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NSIsInJvbGUiOiJ1c2VyIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
It is just a string with three parts separated by dots. Despite the intimidating appearance, the structure is simple.
Structure of a JWT
A JWT has three parts, separated by dots (.):
xxxxx.yyyyy.zzzzz
↑ ↑ ↑
Header Payload Signature
Part 1: Header
The header contains metadata about the token — typically the signing algorithm and token type.
{
"alg": "HS256",
"typ": "JWT"
}
| Field | Meaning |
|---|---|
alg |
Algorithm used for signing (HS256 = HMAC with SHA-256) |
typ |
Token type (always "JWT") |
This JSON is Base64Url encoded into the first part of the token. Base64Url encoding is just a way to convert JSON text into a URL-safe string — it is not encryption, and anyone can decode it.
Part 2: Payload
The payload contains the claims — statements about the user and additional data.
{
"sub": "1234567890",
"name": "John Doe",
"role": "user",
"iat": 1685123456,
"exp": 1685728256
}
| Claim | Meaning | Purpose |
|---|---|---|
sub |
Subject | Identifier of the user (user ID) |
name |
Name | Human-readable name |
role |
Role | Authorization level |
iat |
Issued At | Timestamp when token was created |
exp |
Expiration | Timestamp when token becomes invalid |
Important: The payload is also Base64Url encoded — not encrypted. Anyone who has the token can decode and read the payload. Never put sensitive information (passwords, credit cards, secrets) in a JWT payload.
Part 3: Signature
The signature proves the token has not been tampered with. It is created by:
- Taking the encoded header
- Taking the encoded payload
- Concatenating them with a dot:
encodedHeader + "." + encodedPayload - Running them through a signing algorithm with a secret key
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret_key
)
The server keeps the secret key private. When a token arrives, the server recalculates the signature using the same key and algorithm. If the recalculated signature matches the token's signature, the token is authentic and unmodified.
What the signature prevents:
- A user changing their
rolefrom"user"to"admin"in the payload - A user extending their
expdate to never expire - Anyone creating fake tokens without knowing the secret key
Complete JWT Example
// A real JWT (decoded for illustration)
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NSIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNjg1MTIzNDU2fQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
// Split into parts
const [headerB64, payloadB64, signature] = token.split('.');
// Decode header (Base64Url → JSON)
const header = JSON.parse(atob(headerB64));
// { alg: "HS256", typ: "JWT" }
// Decode payload (Base64Url → JSON)
const payload = JSON.parse(atob(payloadB64));
// { userId: "12345", role: "user", iat: 1685123456 }
// Signature cannot be "decoded" — it must be verified with the secret key
Login Flow Using JWT
Step-by-Step Authentication Flow
┌─────────────┐ ┌──────────────┐
│ Client │ │ Server │
│ (Browser) │ │ │
└──────┬──────┘ └──────┬─────┘
│ │
│ 1. POST /login │
│ { email, password } │
│ ─────────────────────────────────→│
│ │
│ │ 2. Validate credentials
│ │ (check database)
│ │
│ │ 3. Create JWT payload
│ │ { userId, role, iat, exp }
│ │
│ │ 4. Sign with secret key
│ │ → Generate signature
│ │
│ 5. Return JWT │
│ { token: "eyJhbG..." } │
│ ←─────────────────────────────────│
│ │
│ 6. Store token │
│ (localStorage or cookie) │
│ │
Implementation: Login Endpoint
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
// In production, store this in environment variables
const JWT_SECRET = 'your-secret-key-change-this-in-production';
// Mock database
const users = [
{
id: '1',
email: 'alice@example.com',
password: '$2b$10$...', // hashed password
role: 'user'
}
];
// Login endpoint
app.post('/login', async (req, res) => {
const { email, password } = req.body;
// 1. Find user
const user = users.find(u => u.email === email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 2. Verify password
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 3. Create JWT payload
const payload = {
userId: user.id,
role: user.role,
iat: Math.floor(Date.now() / 1000)
};
// 4. Sign and create token (expires in 24 hours)
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' });
// 5. Send token to client
res.json({
message: 'Login successful',
token: token
});
});
What Happens on the Client
// Client-side login request
async function login(email, password) {
const response = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (response.ok) {
// Store token for future requests
localStorage.setItem('token', data.token);
console.log('Login successful');
} else {
console.error('Login failed:', data.error);
}
}
Sending Token With Requests
Once the client has a token, it must send it with every request that requires authentication.
The Authorization Header
The standard way to send a JWT is via the HTTP Authorization header using the Bearer scheme:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Client-Side Request Example
const token = localStorage.getItem('token');
// Include token in every authenticated request
const response = await fetch('/api/profile', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}` // ← Token attached here
}
});
const profile = await response.json();
Using a Request Interceptor (Axios Example)
// Automatically attach token to all requests
axios.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Now every request automatically includes the token
axios.get('/api/profile'); // Token attached automatically
axios.post('/api/orders', { items: [...] }); // Token attached automatically
Protecting Routes Using Tokens
Middleware: The Gatekeeper
Express middleware functions run before route handlers. Authentication middleware checks the token and either allows the request to proceed or rejects it.
// Authentication middleware
function authenticateToken(req, res, next) {
// 1. Get token from Authorization header
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) {
return res.status(401).json({ error: 'Access denied. No token provided.' });
}
// 2. Verify token
jwt.verify(token, JWT_SECRET, (err, decodedPayload) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
// 3. Attach user info to request for route handlers to use
req.user = decodedPayload;
// 4. Proceed to the route handler
next();
});
}
Protected Routes
Apply the middleware to any route that requires authentication:
// Public route — no authentication needed
app.get('/', (req, res) => {
res.send('Public homepage');
});
// Public route
app.post('/login', (req, res) => {
// ... login logic
});
// Protected route — authentication required
app.get('/api/profile', authenticateToken, (req, res) => {
// req.user is available here because authenticateToken attached it
const userId = req.user.userId;
const role = req.user.role;
const user = users.find(u => u.id === userId);
res.json({
id: user.id,
email: user.email,
role: role,
message: 'This is protected data'
});
});
// Protected route
app.post('/api/orders', authenticateToken, (req, res) => {
// Only authenticated users can create orders
const order = {
userId: req.user.userId,
items: req.body.items,
createdAt: new Date()
};
res.status(201).json({ order });
});
Authorization: Role-Based Access
Authentication verifies identity. Authorization checks permissions. Combine both:
// Middleware to check admin role
function requireAdmin(req, res, next) {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
// Admin-only route
app.delete('/api/users/:id', authenticateToken, requireAdmin, (req, res) => {
// Only admins reach this code
const userId = req.params.id;
// ... delete user logic
res.json({ message: `User ${userId} deleted` });
});
Complete Protected Server Example
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
app.use(express.json());
const JWT_SECRET = 'your-secret-key';
// Mock users database
const users = [
{ id: '1', email: 'alice@example.com', password: 'hashed', role: 'user' },
{ id: '2', email: 'admin@example.com', password: 'hashed', role: 'admin' }
];
// Authentication middleware
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: 'Invalid token' });
req.user = user;
next();
});
};
// Routes
app.get('/', (req, res) => res.send('Public API'));
app.post('/login', (req, res) => {
const { email } = req.body;
const user = users.find(u => u.email === email);
if (!user) return res.status(401).json({ error: 'User not found' });
const token = jwt.sign(
{ userId: user.id, role: user.role },
JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token });
});
app.get('/api/profile', authenticateToken, (req, res) => {
res.json({
message: 'Protected profile data',
userId: req.user.userId,
role: req.user.role
});
});
app.get('/api/admin', authenticateToken, (req, res) => {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin only' });
}
res.json({ message: 'Admin dashboard data' });
});
app.listen(3000, () => {
console.log('Server running. Test with:');
console.log(' curl -X POST http://localhost:3000/login -H "Content-Type: application/json" -d \'{"email":"alice@example.com"}\'');
console.log(' curl http://localhost:3000/api/profile -H "Authorization: Bearer <token>"');
});
JWT Security Best Practices
| Practice | Why It Matters |
|---|---|
| Keep the secret key secret | Anyone with the key can forge tokens |
| Set expiration times | Stolen tokens become useless after expiry |
| Use HTTPS | Prevents token interception in transit |
| Don't store sensitive data in payload | Payload is readable by anyone who has the token |
| Validate on every protected request | Never trust tokens without verification |
| Use strong signing algorithms | HS256 or RS256, not weaker alternatives |
| Implement token refresh | Short-lived access tokens + long-lived refresh tokens |
Summary
| Concept | What It Means |
|---|---|
| Authentication | Verifying who a user is (identity proof) |
| Stateful auth | Server stores session data in memory/database |
| Stateless auth | Server stores nothing; all data is in the token |
| JWT | Self-contained, signed token with header, payload, and signature |
| Header | Metadata: algorithm and token type |
| Payload | Claims about the user: ID, role, timestamps |
| Signature | Proof the token was issued by the server and not tampered with |
| Bearer token | Token sent in Authorization: Bearer <token> header |
| Middleware | Function that runs before route handlers to verify tokens |
JWT transformed authentication by eliminating server-side session storage. Instead of looking up sessions in a database on every request, the server simply verifies a signature. The token carries its own proof of authenticity. This makes applications easier to scale, simpler to deploy across multiple servers, and faster for every authenticated request. The trade-off is careful secret management and short expiration times — but those are solvable operational concerns, not architectural limitations.
Remember: A JWT is like a tamper-evident wristband at a concert. It contains your access level, is issued by a trusted authority, and has a seal that proves it is genuine. Staff at every checkpoint just inspect the band — they do not look you up in a database. Stateless, self-contained, efficient.
Top comments (0)