
JSON Web Tokens (JWT) are one of the most widely used technologies for implementing authentication in modern web applications.
You will find JWT in:
- Angular applications
- React applications
- Node.js APIs
- Mobile applications
- Microservices
- BFF architectures
- API gateways
- Distributed systems
But JWT is often misunderstood.
Many developers know how to write:
jwt.sign(...) and:
jwt.verify(...) without really understanding what a JWT is, what problem it solves, how it works internally, where it should be stored, and how to secure it properly.
This article goes beyond simply generating a token.
We will understand JWT from the ground up and build a complete authentication flow using Node.js, Express, TypeScript, and MongoDB-style concepts.
1. Authentication vs Authorization
Before discussing JWT, we need to understand two fundamental concepts.
Authentication
Authentication answers:
Who are you?
For example:
User → Email + Password ↓ Server ↓ Is the user valid? ↓ Yes The server identifies the user.
Authorization
Authorization answers:
What are you allowed to do?
For example:
User ├── Read products ✅ ├── Create products ❌ ├── Delete products ❌ └── Manage users ❌ Authentication comes first.
Authorization comes after authentication.
2. What Is JWT?
JWT stands for:
JSON Web Token
It is a compact token format used to securely transmit claims between parties.
A JWT typically looks like this:
xxxxx.yyyyy.zzzzz For example:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiIxMjM0NSIsInJvbGUiOiJ1c2VyIn0 . SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c A JWT contains three parts:
HEADER.PAYLOAD.SIGNATURE 3. JWT Structure
Let's break it down.
Header
The header describes the token.
Example:
{ "alg": "HS256", "typ": "JWT" } alg represents the signing algorithm.
typ represents the token type.
Payload
The payload contains claims.
Example:
{ "sub": "12345", "email": "user@example.com", "role": "admin" } Claims are pieces of information about the token or its subject.
Common claims include:
sub iss aud exp iat nbf jti For example:
{ "sub": "12345", "role": "admin", "iat": 1720000000, "exp": 1720003600 } Signature
The signature allows the server to verify that the token has not been modified.
Conceptually:
signature = HMAC( base64(header) + "." + base64(payload), secret ) So the final token becomes:
HEADER.PAYLOAD.SIGNATURE 4. Important: JWT Is Not Encryption
This is one of the most important JWT concepts.
A normal JWT payload is not encrypted.
It is encoded using Base64URL.
For example:
{ "email": "user@example.com", "role": "admin" } can be decoded.
Therefore, you should never put sensitive information inside a JWT.
Don't do this:
{ "password": "MySuperSecretPassword" } Don't store:
credit card numbers passwords private secrets sensitive personal data inside the payload.
JWT provides integrity and authenticity through signing, not confidentiality.
If you need confidentiality, encryption is a different concept.
5. Why Do We Need JWT?
Imagine an API:
POST /login The user sends:
{ "email": "user@example.com", "password": "123456" } The server validates the credentials.
Now the user wants:
GET /api/profile How does the server know who the user is?
One traditional solution is a server-side session.
Another solution is a JWT.
The server can issue:
Access Token The client then sends the token with future requests.
For example:
Authorization: Bearer eyJhbGciOi... The server verifies the token.
6. JWT Authentication Flow
A typical authentication flow looks like this:
Client | | email + password v POST /login | v Server | Validate credentials | v Generate JWT | v Client | Store/use token | | Authorization: Bearer JWT v Protected API | Verify JWT | v Response The important idea is:
The server doesn't need to receive the user's password on every request.
7. Stateless Authentication
One major advantage of JWT is that authentication can be stateless.
With traditional sessions:
Client | Session ID | Server | Session Store The server needs to maintain session state.
With JWT:
Client | JWT | Server The token itself contains the claims required to identify the user.
The server can verify the signature without necessarily looking up a session.
This can be useful in distributed systems.
8. JWT in a Microservices Architecture
Consider:
Client | v API Gateway | +----------+----------+ | | | v v v Users Orders Payments Service Service Service The client can send:
Authorization: Bearer <token> Each service can validate the token.
This is one reason JWT is popular in distributed architectures.
However, "stateless" does not mean JWT automatically solves every distributed authentication problem.
Revocation, rotation, expiration, key management, and authorization still need to be designed carefully.
9. Creating JWTs with Node.js
Let's build a simple example.
Install:
npm install express jsonwebtoken bcrypt For TypeScript:
npm install -D @types/express @types/jsonwebtoken @types/bcrypt 10. JWT Secret
Never hardcode your production secret:
const JWT_SECRET = "my-secret"; Instead:
JWT_SECRET=super-long-random-secret Then:
const JWT_SECRET = process.env.JWT_SECRET!; A production secret should be long, random, and protected.
For stronger architectures, asymmetric keys such as RSA or EC keys can be used instead of a shared HMAC secret.
11. Generating an Access Token
Example:
import jwt from "jsonwebtoken"; const token = jwt.sign( { sub: user.id, role: user.role }, process.env.JWT_SECRET!, { expiresIn: "15m" } ); The resulting token contains claims such as:
{ "sub": "12345", "role": "user", "iat": 1720000000, "exp": 1720000900 } 12. Why Use sub?
sub means:
Subject
It commonly identifies the user.
Instead of putting:
{ "userId": "12345" } you can use:
{ "sub": "12345" } Then your application can interpret:
const userId = payload.sub; 13. Login Endpoint
A simplified login endpoint might look like this:
app.post("/auth/login", async (req, res) => { const { email, password } = req.body; const user = await User.findOne({ email }); if (!user) { return res.status(401).json({ message: "Invalid credentials" }); } const validPassword = await bcrypt.compare( password, user.password ); if (!validPassword) { return res.status(401).json({ message: "Invalid credentials" }); } const accessToken = jwt.sign( { sub: user.id, role: user.role }, process.env.JWT_SECRET!, { expiresIn: "15m" } ); res.json({ accessToken }); }); The process is:
Email ↓ Find user ↓ Compare password hash ↓ Generate access token ↓ Return token 14. Passwords Should Never Be Stored as Plain Text
Never do:
user.password = password; Instead, hash the password.
Example:
const hashedPassword = await bcrypt.hash(password, 12); Store:
hashedPassword not:
password During login:
const valid = await bcrypt.compare( password, hashedPassword ); JWT does not replace password hashing.
They solve different problems.
bcrypt ↓ Password protection JWT ↓ Authentication token 15. Protecting Routes with Middleware
Now we need to protect:
GET /api/profile We can create authentication middleware.
import { Request, Response, NextFunction } from "express"; import jwt from "jsonwebtoken"; export interface AuthRequest extends Request { user?: { id: string; role: string; }; } export function authenticate( req: AuthRequest, res: Response, next: NextFunction ) { const authHeader = req.headers.authorization; if (!authHeader) { return res.status(401).json({ message: "Authentication required" }); } const [scheme, token] = authHeader.split(" "); if (scheme !== "Bearer" || !token) { return res.status(401).json({ message: "Invalid authorization header" }); } try { const payload = jwt.verify( token, process.env.JWT_SECRET! ) as jwt.JwtPayload; req.user = { id: payload.sub as string, role: payload.role as string }; next(); } catch { return res.status(401).json({ message: "Invalid or expired token" }); } } 16. Using the Middleware
Now:
app.get( "/api/profile", authenticate, (req: AuthRequest, res) => { res.json({ message: "Protected profile", user: req.user }); } ); The request flow becomes:
Request | Authorization Header | Extract JWT | Verify Signature | Check Expiration | Extract User | next() | Controller 17. What Happens When the Token Is Modified?
Suppose the original payload is:
{ "sub": "123", "role": "user" } An attacker changes it to:
{ "sub": "123", "role": "admin" } The attacker cannot simply change the payload and create a valid signature.
The server calculates/verifies the signature using its trusted key.
The modified token fails verification.
Therefore:
Original Token ↓ Valid Signature ↓ Accepted Modified Token ↓ Invalid Signature ↓ Rejected That's the core security property of signed JWTs.
18. JWT Verification vs JWT Decoding
This distinction is extremely important.
You might see:
jwt.decode(token); and think:
"The token is valid."
No.
decode() only decodes the token.
It does not prove that the token was signed by a trusted issuer.
Use:
jwt.verify(token, secret); for authentication.
Conceptually:
decode() ↓ Read data verify() ↓ Validate signature + token constraints Never authenticate a user using only decode().
19. JWT Expiration
JWTs should normally expire.
Example:
jwt.sign( { sub: user.id }, JWT_SECRET, { expiresIn: "15m" } ); Why?
Because if a token is stolen, a long-lived token gives an attacker a long window of opportunity.
Short-lived access tokens reduce this risk.
A common architecture is:
Access Token 15 minutes Refresh Token Longer lifetime 20. Access Token vs Refresh Token
This is one of the most important patterns in modern authentication.
Access Token
Used to access APIs.
Example:
Lifetime: 5–15 minutes Refresh Token
Used to obtain a new access token.
Example:
Lifetime: days/weeks The architecture becomes:
Login | +-------+-------+ | | v v Access Token Refresh Token short-lived long-lived | v API When the access token expires:
Client | | refresh token v POST /auth/refresh | v Server | v New Access Token 21. Why Not Make the Access Token Last 30 Days?
You technically can.
But it's generally a bad security tradeoff.
Imagine:
Access Token Expires in 30 days If it gets stolen today, the attacker may have a valid credential for a long period.
Instead:
Access Token Expires in 15 minutes and:
Refresh Token Longer lifetime This limits the lifetime of the credential used for normal API access.
22. Refresh Token Rotation
A stronger architecture can rotate refresh tokens.
Example:
Refresh Token A | v Refresh Endpoint | +----> Access Token B | +----> Refresh Token B The old refresh token becomes invalid.
This can help detect replay attacks and reduce the usefulness of stolen refresh tokens.
A server-side store is often used to track refresh-token state.
For example:
Redis refresh-token-id → user/session metadata or a database.
23. JWT Storage: Where Should You Store It?
This is one of the most debated JWT topics.
Possible locations include:
localStorage sessionStorage cookies memory Each has trade-offs.
24. localStorage
Example:
localStorage.setItem( "accessToken", token ); Then:
const token = localStorage.getItem( "accessToken" ); The problem is that JavaScript can access localStorage.
If your application has an XSS vulnerability, malicious JavaScript may potentially read the token.
Therefore:
Storing long-lived authentication credentials in localStorage requires careful consideration of XSS risk.
25. HttpOnly Cookies
A common alternative is:
Set-Cookie: refreshToken=...; HttpOnly; Secure; SameSite=Lax HttpOnly means client-side JavaScript cannot directly read the cookie.
This reduces the impact of token theft through straightforward JavaScript access.
However, cookies introduce another concern:
CSRF
Therefore, cookie-based authentication must also use appropriate CSRF protections and cookie configuration.
26. A Common Modern Architecture
A practical pattern is:
Access Token ↓ Short-lived ↓ Memory Refresh Token ↓ Long-lived ↓ HttpOnly + Secure Cookie For example:
Browser | +---- Access Token → memory | +---- Refresh Token → HttpOnly Cookie When the page reloads:
Browser | | Refresh Cookie v /auth/refresh | v New Access Token This is often preferable to keeping a long-lived access token in JavaScript-readable storage.
The exact design depends on the application architecture and threat model.
27. JWT and XSS
JWT does not magically protect your application from XSS.
Suppose an attacker injects:
<script> // malicious code </script> If your access token is available to JavaScript:
localStorage.getItem("accessToken"); the attacker may potentially access it.
Therefore, authentication security must include:
XSS protection + Secure token handling + Content Security Policy + Input/output handling JWT is only one part of the security architecture.
28. JWT and CSRF
If you use cookies for authentication, browsers automatically attach cookies to matching requests.
This creates CSRF considerations.
For example:
Victim Browser | | Cookie automatically attached v Bank API A malicious website may try to trigger an unwanted request.
Common defenses include:
SameSite cookies CSRF tokens Origin/Referer validation Proper request design The correct strategy depends on the architecture.
29. Role-Based Authorization
Authentication tells us:
Who is the user? Authorization tells us:
What can the user do? Suppose the JWT contains:
{ "sub": "123", "role": "admin" } We can create an authorization middleware.
function authorize(...roles: string[]) { return ( req: AuthRequest, res: Response, next: NextFunction ) => { if (!req.user) { return res.status(401).json({ message: "Authentication required" }); } if (!roles.includes(req.user.role)) { return res.status(403).json({ message: "Forbidden" }); } next(); }; } Use it:
app.delete( "/api/users/:id", authenticate, authorize("admin"), deleteUser ); Now:
No Token ↓ 401 Unauthorized Valid Token + user role ↓ 403 Forbidden Valid Token + admin role ↓ Allowed 30. 401 vs 403
This distinction matters.
401 Unauthorized
Usually means:
The request does not have valid authentication credentials.
Examples:
Missing token Expired token Invalid token 403 Forbidden
Usually means:
The user is authenticated but does not have permission.
Example:
Authenticated user + role = user ↓ Admin endpoint ↓ 403 31. Don't Trust Authorization Claims Blindly
Suppose the JWT says:
{ "sub": "123", "role": "admin" } Should your application always trust the role forever?
Not necessarily.
Imagine:
User is admin ↓ JWT issued ↓ Admin account downgraded ↓ JWT still valid The old token may continue carrying:
role = admin until it expires.
This is one reason authorization design sometimes requires server-side state.
For highly sensitive operations, the application may re-check the user's current permissions.
32. JWT Claims
Let's examine common claims.
iss
Issuer:
{ "iss": "https://auth.example.com" } Identifies who issued the token.
aud
Audience:
{ "aud": "api.example.com" } Identifies the intended recipient.
sub
Subject:
{ "sub": "user-123" } Identifies the principal.
exp
Expiration:
{ "exp": 1720003600 } After this time, the token should no longer be accepted.
iat
Issued-at time:
{ "iat": 1720000000 } nbf
Not-before:
{ "nbf": 1720000100 } The token should not be accepted before this time.
jti
JWT ID:
{ "jti": "unique-token-id"Source:https://dev.to/abanoubkerols/jwt-authentication-explained-theory-security-and-a-complete-nodejs-express-example-264 Related posts
Three dates that catch a hard-coded time-zone bug in a session clock
Laravel N+1 Query Problem: How to Find and Fix It
LongCat-2.5 at $0.30/$1.20: Thinking Locks Temperature, 1M Context, and Vendor Benches Between Opus 4.8 and 5
Shipped: two packages, a registry listing, and the first seals
AI Agent Memory Needs Garbage Collection, Not Just Storage
