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:

Related posts