DEV Community

Cover image for JWT Authentication Explained: Theory, Security, and a Complete Node.js & Express Example
Abanoub Kerols
Abanoub Kerols

Posted on

JWT Authentication Explained: Theory, Security, and a Complete Node.js & Express Example


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(...)
Enter fullscreen mode Exit fullscreen mode

and:

jwt.verify(...)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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        ❌
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For example:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjM0NSIsInJvbGUiOiJ1c2VyIn0
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Enter fullscreen mode Exit fullscreen mode

A JWT contains three parts:

HEADER.PAYLOAD.SIGNATURE
Enter fullscreen mode Exit fullscreen mode

3. JWT Structure

Let's break it down.

Header

The header describes the token.

Example:

{
  "alg": "HS256",
  "typ": "JWT"
}
Enter fullscreen mode Exit fullscreen mode

alg represents the signing algorithm.

typ represents the token type.


Payload

The payload contains claims.

Example:

{
  "sub": "12345",
  "email": "user@example.com",
  "role": "admin"
}
Enter fullscreen mode Exit fullscreen mode

Claims are pieces of information about the token or its subject.

Common claims include:

sub
iss
aud
exp
iat
nbf
jti
Enter fullscreen mode Exit fullscreen mode

For example:

{
  "sub": "12345",
  "role": "admin",
  "iat": 1720000000,
  "exp": 1720003600
}
Enter fullscreen mode Exit fullscreen mode

Signature

The signature allows the server to verify that the token has not been modified.

Conceptually:

signature =
    HMAC(
        base64(header) + "." + base64(payload),
        secret
    )
Enter fullscreen mode Exit fullscreen mode

So the final token becomes:

HEADER.PAYLOAD.SIGNATURE
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

can be decoded.

Therefore, you should never put sensitive information inside a JWT.

Don't do this:

{
  "password": "MySuperSecretPassword"
}
Enter fullscreen mode Exit fullscreen mode

Don't store:

credit card numbers
passwords
private secrets
sensitive personal data
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The user sends:

{
  "email": "user@example.com",
  "password": "123456"
}
Enter fullscreen mode Exit fullscreen mode

The server validates the credentials.

Now the user wants:

GET /api/profile
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The client then sends the token with future requests.

For example:

Authorization: Bearer eyJhbGciOi...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The server needs to maintain session state.

With JWT:

Client
   |
JWT
   |
Server
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The client can send:

Authorization: Bearer <token>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For TypeScript:

npm install -D @types/express @types/jsonwebtoken @types/bcrypt
Enter fullscreen mode Exit fullscreen mode

10. JWT Secret

Never hardcode your production secret:

const JWT_SECRET = "my-secret";
Enter fullscreen mode Exit fullscreen mode

Instead:

JWT_SECRET=super-long-random-secret
Enter fullscreen mode Exit fullscreen mode

Then:

const JWT_SECRET = process.env.JWT_SECRET!;
Enter fullscreen mode Exit fullscreen mode

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"
  }
);
Enter fullscreen mode Exit fullscreen mode

The resulting token contains claims such as:

{
  "sub": "12345",
  "role": "user",
  "iat": 1720000000,
  "exp": 1720000900
}
Enter fullscreen mode Exit fullscreen mode

12. Why Use sub?

sub means:

Subject

It commonly identifies the user.

Instead of putting:

{
  "userId": "12345"
}
Enter fullscreen mode Exit fullscreen mode

you can use:

{
  "sub": "12345"
}
Enter fullscreen mode Exit fullscreen mode

Then your application can interpret:

const userId = payload.sub;
Enter fullscreen mode Exit fullscreen mode

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
  });
});
Enter fullscreen mode Exit fullscreen mode

The process is:

Email
 ↓
Find user
 ↓
Compare password hash
 ↓
Generate access token
 ↓
Return token
Enter fullscreen mode Exit fullscreen mode

14. Passwords Should Never Be Stored as Plain Text

Never do:

user.password = password;
Enter fullscreen mode Exit fullscreen mode

Instead, hash the password.

Example:

const hashedPassword = await bcrypt.hash(password, 12);
Enter fullscreen mode Exit fullscreen mode

Store:

hashedPassword
Enter fullscreen mode Exit fullscreen mode

not:

password
Enter fullscreen mode Exit fullscreen mode

During login:

const valid = await bcrypt.compare(
  password,
  hashedPassword
);
Enter fullscreen mode Exit fullscreen mode

JWT does not replace password hashing.

They solve different problems.

bcrypt
   ↓
Password protection

JWT
   ↓
Authentication token
Enter fullscreen mode Exit fullscreen mode

15. Protecting Routes with Middleware

Now we need to protect:

GET /api/profile
Enter fullscreen mode Exit fullscreen mode

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"
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

16. Using the Middleware

Now:

app.get(
  "/api/profile",
  authenticate,
  (req: AuthRequest, res) => {
    res.json({
      message: "Protected profile",
      user: req.user
    });
  }
);
Enter fullscreen mode Exit fullscreen mode

The request flow becomes:

Request
   |
Authorization Header
   |
Extract JWT
   |
Verify Signature
   |
Check Expiration
   |
Extract User
   |
next()
   |
Controller
Enter fullscreen mode Exit fullscreen mode

17. What Happens When the Token Is Modified?

Suppose the original payload is:

{
  "sub": "123",
  "role": "user"
}
Enter fullscreen mode Exit fullscreen mode

An attacker changes it to:

{
  "sub": "123",
  "role": "admin"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

for authentication.

Conceptually:

decode()
   ↓
Read data

verify()
   ↓
Validate signature + token constraints
Enter fullscreen mode Exit fullscreen mode

Never authenticate a user using only decode().


19. JWT Expiration

JWTs should normally expire.

Example:

jwt.sign(
  {
    sub: user.id
  },
  JWT_SECRET,
  {
    expiresIn: "15m"
  }
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Refresh Token

Used to obtain a new access token.

Example:

Lifetime: days/weeks
Enter fullscreen mode Exit fullscreen mode

The architecture becomes:

              Login
                |
        +-------+-------+
        |               |
        v               v
 Access Token      Refresh Token
   short-lived       long-lived
        |
        v
      API
Enter fullscreen mode Exit fullscreen mode

When the access token expires:

Client
  |
  | refresh token
  v
POST /auth/refresh
  |
  v
Server
  |
  v
New Access Token
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If it gets stolen today, the attacker may have a valid credential for a long period.

Instead:

Access Token
Expires in 15 minutes
Enter fullscreen mode Exit fullscreen mode

and:

Refresh Token
Longer lifetime
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Each has trade-offs.


24. localStorage

Example:

localStorage.setItem(
  "accessToken",
  token
);
Enter fullscreen mode Exit fullscreen mode

Then:

const token = localStorage.getItem(
  "accessToken"
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For example:

Browser
  |
  +---- Access Token → memory
  |
  +---- Refresh Token → HttpOnly Cookie
Enter fullscreen mode Exit fullscreen mode

When the page reloads:

Browser
   |
   | Refresh Cookie
   v
/auth/refresh
   |
   v
New Access Token
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

If your access token is available to JavaScript:

localStorage.getItem("accessToken");
Enter fullscreen mode Exit fullscreen mode

the attacker may potentially access it.

Therefore, authentication security must include:

XSS protection
+
Secure token handling
+
Content Security Policy
+
Input/output handling
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A malicious website may try to trigger an unwanted request.

Common defenses include:

SameSite cookies
CSRF tokens
Origin/Referer validation
Proper request design
Enter fullscreen mode Exit fullscreen mode

The correct strategy depends on the architecture.


29. Role-Based Authorization

Authentication tells us:

Who is the user?
Enter fullscreen mode Exit fullscreen mode

Authorization tells us:

What can the user do?
Enter fullscreen mode Exit fullscreen mode

Suppose the JWT contains:

{
  "sub": "123",
  "role": "admin"
}
Enter fullscreen mode Exit fullscreen mode

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();
  };
}
Enter fullscreen mode Exit fullscreen mode

Use it:

app.delete(
  "/api/users/:id",
  authenticate,
  authorize("admin"),
  deleteUser
);
Enter fullscreen mode Exit fullscreen mode

Now:

No Token
   ↓
401 Unauthorized

Valid Token + user role
   ↓
403 Forbidden

Valid Token + admin role
   ↓
Allowed
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

403 Forbidden

Usually means:

The user is authenticated but does not have permission.

Example:

Authenticated user
        +
role = user
        ↓
Admin endpoint
        ↓
403
Enter fullscreen mode Exit fullscreen mode

31. Don't Trust Authorization Claims Blindly

Suppose the JWT says:

{
  "sub": "123",
  "role": "admin"
}
Enter fullscreen mode Exit fullscreen mode

Should your application always trust the role forever?

Not necessarily.

Imagine:

User is admin
     ↓
JWT issued
     ↓
Admin account downgraded
     ↓
JWT still valid
Enter fullscreen mode Exit fullscreen mode

The old token may continue carrying:

role = admin
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Identifies who issued the token.


aud

Audience:

{
  "aud": "api.example.com"
}
Enter fullscreen mode Exit fullscreen mode

Identifies the intended recipient.


sub

Subject:

{
  "sub": "user-123"
}
Enter fullscreen mode Exit fullscreen mode

Identifies the principal.


exp

Expiration:

{
  "exp": 1720003600
}
Enter fullscreen mode Exit fullscreen mode

After this time, the token should no longer be accepted.


iat

Issued-at time:

{
  "iat": 1720000000
}
Enter fullscreen mode Exit fullscreen mode

nbf

Not-before:

{
  "nbf": 1720000100
}
Enter fullscreen mode Exit fullscreen mode

The token should not be accepted before this time.


jti

JWT ID:

{
  "jti": "unique-token-id"
}
Enter fullscreen mode Exit fullscreen mode

Useful for token identification and certain revocation/replay-detection designs.


33. Algorithm Choice

JWT supports different signing algorithms.

Examples:

HS256
RS256
ES256
Enter fullscreen mode Exit fullscreen mode

HS256

Uses one shared secret.

Private signing secret
        +
Verification secret
Enter fullscreen mode Exit fullscreen mode

Both sides need access to the same secret.

This can be simple for a single backend.


RS256

Uses asymmetric cryptography.

Private Key
    ↓
Sign

Public Key
    ↓
Verify
Enter fullscreen mode Exit fullscreen mode

This is particularly useful in distributed architectures.

For example:

Authorization Server
        |
   Private Key
        |
       JWT
        |
        v
+-------+-------+-------+
|       |       |       |
v       v       v       v
API 1   API 2   API 3   API 4
        |
    Public Key
Enter fullscreen mode Exit fullscreen mode

Services can verify tokens without possessing the private signing key.


34. Never Accept Arbitrary Algorithms

A dangerous implementation mistake is allowing the token itself to dictate a signing algorithm without enforcing what your application expects.

Your verification configuration should explicitly define trusted algorithms.

Conceptually:

jwt.verify(token, publicKey, {
  algorithms: ["RS256"]
});
Enter fullscreen mode Exit fullscreen mode

The server should have a clear trust policy.


35. Complete Authentication Architecture

A more production-oriented architecture could look like this:

                    Browser
                       |
                       v
                  Login Request
                       |
                       v
                Authentication API
                       |
              Validate credentials
                       |
          +------------+------------+
          |                         |
          v                         v
    Access Token              Refresh Token
    Short Lifetime             Long Lifetime
          |                         |
          v                         v
      API Calls               HttpOnly Cookie
          |                         |
          v                         |
      JWT Verify                   |
          |                         |
          +------------+------------+
                       |
                       v
                    API
                       |
                Authorization
                       |
                       v
                   Resource
Enter fullscreen mode Exit fullscreen mode

36. Refresh Endpoint

A simplified implementation might look like:

app.post("/auth/refresh", async (req, res) => {
  const refreshToken = req.cookies.refreshToken;

  if (!refreshToken) {
    return res.status(401).json({
      message: "Refresh token required"
    });
  }

  try {
    const payload = jwt.verify(
      refreshToken,
      process.env.REFRESH_TOKEN_SECRET!
    ) as jwt.JwtPayload;

    const accessToken = jwt.sign(
      {
        sub: payload.sub,
        role: payload.role
      },
      process.env.JWT_SECRET!,
      {
        expiresIn: "15m"
      }
    );

    res.json({
      accessToken
    });
  } catch {
    res.status(401).json({
      message: "Invalid refresh token"
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

A production implementation should go further.

For example:

Refresh Token
      ↓
Verify signature
      ↓
Check expiration
      ↓
Check session/token record
      ↓
Check revocation
      ↓
Rotate token
      ↓
Issue new access token
Enter fullscreen mode Exit fullscreen mode

37. Logout with JWT

With traditional sessions:

DELETE session
Enter fullscreen mode Exit fullscreen mode

and the server can immediately invalidate it.

JWT introduces an interesting problem.

If the access token is self-contained and valid for 15 minutes, how do you instantly revoke it?

There are several strategies.

Strategy 1: Short expiration

Use:

5–15 minute access token
Enter fullscreen mode Exit fullscreen mode

and accept that it remains valid until expiration.


Strategy 2: Token Revocation

Store token identifiers:

jti → revoked
Enter fullscreen mode Exit fullscreen mode

Then check them during authentication.

This introduces server-side state.


Strategy 3: Refresh Token Revocation

A common design is to invalidate the refresh session during logout.

Then:

Existing access token
     ↓
Expires soon

Refresh token
     ↓
Revoked
Enter fullscreen mode Exit fullscreen mode

The user cannot obtain another access token.


38. JWT Blacklist

Another approach is a blacklist.

For example:

Redis

revoked:jti:12345
Enter fullscreen mode Exit fullscreen mode

During authentication:

JWT
 ↓
Verify signature
 ↓
Read jti
 ↓
Check Redis
 ↓
Revoked?
 ├── Yes → Reject
 └── No  → Continue
Enter fullscreen mode Exit fullscreen mode

This gives stronger revocation control but sacrifices some of JWT's stateless simplicity.


39. JWT Is Not Always the Best Choice

JWT is powerful, but it isn't automatically the correct authentication mechanism for every application.

For a traditional server-rendered application, a session-based approach can be simpler.

For example:

Browser
   |
Session Cookie
   |
Server
   |
Session Store
Enter fullscreen mode Exit fullscreen mode

For distributed APIs:

Client
   |
JWT
   |
API Gateway
   |
Microservices
Enter fullscreen mode Exit fullscreen mode

The correct choice depends on the architecture.

Don't choose JWT simply because:

"Everyone uses JWT."

Choose it because its properties fit the system.


40. Common JWT Mistakes

Here are some mistakes developers frequently make.

Mistake 1 — Putting passwords in JWT

Bad:

{
  "email": "user@example.com",
  "password": "123456"
}
Enter fullscreen mode Exit fullscreen mode

Never do this.


Mistake 2 — Treating Base64 as encryption

Bad assumption:

Encoded = Encrypted
Enter fullscreen mode Exit fullscreen mode

False.

JWT payloads are normally readable.


Mistake 3 — Using decode() for authentication

Bad:

const payload = jwt.decode(token);
Enter fullscreen mode Exit fullscreen mode

Use verification instead:

jwt.verify(token, secret);
Enter fullscreen mode Exit fullscreen mode

Mistake 4 — Very long-lived access tokens

Bad:

Access Token = 30 days
Enter fullscreen mode Exit fullscreen mode

Prefer short-lived access tokens when appropriate.


Mistake 5 — Hardcoding secrets

Bad:

const secret = "123456";
Enter fullscreen mode Exit fullscreen mode

Use secure secret management.


Mistake 6 — Storing everything in the token

Don't turn JWT into a database.

Bad:

{
  "sub": "123",
  "name": "...",
  "address": "...",
  "permissions": [ "...", "...", "..."],
  "orders": [ "...", "...", "..." ]
}
Enter fullscreen mode Exit fullscreen mode

JWTs should generally remain small.


Mistake 7 — Putting sensitive data in the payload

Remember:

JWT payload ≠ encrypted storage
Enter fullscreen mode Exit fullscreen mode

Mistake 8 — Forgetting expiration

A token without appropriate expiration can become a long-lived credential.


Mistake 9 — Ignoring CSRF when using cookies

Cookie-based authentication requires appropriate CSRF defenses.


Mistake 10 — Assuming JWT solves logout

JWT does not automatically provide instant revocation.


41. Angular + JWT Example

Since JWT is frequently used with Angular, let's see the client-side concept.

An Angular HTTP interceptor can attach an access token:

export const authInterceptor: HttpInterceptorFn = (
  req,
  next
) => {
  const token = authService.getAccessToken();

  if (!token) {
    return next(req);
  }

  const authReq = req.clone({
    setHeaders: {
      Authorization: `Bearer ${token}`
    }
  });

  return next(authReq);
};
Enter fullscreen mode Exit fullscreen mode

The request becomes:

GET /api/profile HTTP/1.1
Authorization: Bearer eyJhbGciOi...
Enter fullscreen mode Exit fullscreen mode

The backend then verifies the token.


42. Handling Expired Tokens in Angular

Suppose the API returns:

401 Unauthorized
Enter fullscreen mode Exit fullscreen mode

The frontend can attempt a refresh flow.

Conceptually:

Angular
   |
API Request
   |
401
   |
Refresh Token
   |
/auth/refresh
   |
New Access Token
   |
Retry Original Request
Enter fullscreen mode Exit fullscreen mode

RxJS is especially useful for implementing this flow safely.

But you must avoid creating infinite refresh loops.

For example:

API request
   ↓
401
   ↓
refresh
   ↓
401
   ↓
refresh
   ↓
401
   ↓
...
Enter fullscreen mode Exit fullscreen mode

A production interceptor must carefully control refresh attempts.


43. JWT in BFF Architecture

JWT also appears in BFF architectures.

For example:

                 Browser
                    |
                    v
                   BFF
                    |
          +---------+---------+
          |         |         |
          v         v         v
       Users     Orders    Payments
Enter fullscreen mode Exit fullscreen mode

The BFF can manage authentication while keeping backend credentials away from browser code.

A common architecture is:

Browser
   |
Secure Cookie
   |
BFF
   |
Access Token
   |
Backend APIs
Enter fullscreen mode Exit fullscreen mode

This can simplify browser security and token management.


44. JWT in an API Gateway

Another architecture:

Client
  |
  | JWT
  v
API Gateway
  |
  | verify
  v
Microservices
Enter fullscreen mode Exit fullscreen mode

The gateway may verify:

Signature
Issuer
Audience
Expiration
Enter fullscreen mode Exit fullscreen mode

Then forward trusted identity information downstream.

However, individual services may still need to perform their own authorization checks.

Authentication at the gateway does not automatically mean:

Every service should trust every request from the gateway.

Trust boundaries must be designed explicitly.


45. A Better Production JWT Checklist

Before shipping JWT authentication, ask:

☑ Is the token signed with a strong algorithm?

☑ Are signing keys stored securely?

☑ Are tokens short-lived?

☑ Is refresh-token rotation implemented where appropriate?

☑ Are refresh tokens revocable?

☑ Are sensitive values excluded from the payload?

☑ Is JWT verification used instead of decode?

☑ Are issuer and audience validated where appropriate?

☑ Are accepted algorithms restricted?

☑ Is XSS protection implemented?

☑ If cookies are used, is CSRF addressed?

☑ Are cookies Secure?

☑ Are cookies HttpOnly when appropriate?

☑ Is SameSite configured appropriately?

☑ Are passwords hashed using a strong password hashing algorithm?

☑ Is authorization checked separately from authentication?

☑ Is logout behavior clearly defined?

☑ Is token revocation strategy documented?

☑ Are secrets/keys managed outside source control?
Enter fullscreen mode Exit fullscreen mode

46. The Most Important Mental Model

Don't think of JWT as:

"A token that logs the user in."

Think of it as:

JWT
 |
 +-- Identity
 |
 +-- Claims
 |
 +-- Expiration
 |
 +-- Issuer/Audience
 |
 +-- Cryptographic Integrity
Enter fullscreen mode Exit fullscreen mode

Then authentication becomes:

Request
   |
   v
Extract Token
   |
   v
Verify Signature
   |
   v
Validate Claims
   |
   +---- exp
   +---- iss
   +---- aud
   +---- nbf
   |
   v
Identify User
   |
   v
Authorization
   |
   v
Allow / Reject
Enter fullscreen mode Exit fullscreen mode

That mental model is much more useful than memorizing:

jwt.sign()
jwt.verify()
Enter fullscreen mode Exit fullscreen mode

47. Complete Example

A simplified backend structure could look like:

src/
├── auth/
│   ├── auth.controller.ts
│   ├── auth.service.ts
│   ├── auth.middleware.ts
│   └── auth.types.ts
│
├── users/
│   ├── user.model.ts
│   ├── user.controller.ts
│   └── user.service.ts
│
├── middleware/
│   └── error.middleware.ts
│
├── app.ts
└── server.ts
Enter fullscreen mode Exit fullscreen mode

Authentication flow:

POST /auth/register
        |
        v
Hash Password
        |
        v
Create User
Enter fullscreen mode Exit fullscreen mode

Then:

POST /auth/login
        |
        v
Validate Credentials
        |
        v
Create Access Token
        |
        v
Create Refresh Token
        |
        v
Return / Set Cookies
Enter fullscreen mode Exit fullscreen mode

Then:

GET /api/profile
        |
        v
Authenticate Middleware
        |
        v
Verify JWT
        |
        v
Authorization
        |
        v
Controller
Enter fullscreen mode Exit fullscreen mode

And finally:

POST /auth/refresh
        |
        v
Validate Refresh Token
        |
        v
Rotate Refresh Token
        |
        v
Issue New Access Token
Enter fullscreen mode Exit fullscreen mode

48. Final Takeaway

JWT is not simply a replacement for sessions.

It is a token format with cryptographic signing capabilities that can be used as part of an authentication and authorization architecture.

The most important concepts to remember are:

JWT
│
├── Header
│
├── Payload
│
└── Signature
Enter fullscreen mode Exit fullscreen mode

And:

Authentication
      ↓
Who are you?

Authorization
      ↓
What can you do?
Enter fullscreen mode Exit fullscreen mode

For a production system:

Short-lived Access Token
          +
Secure Refresh Token
          +
Strong Key Management
          +
HTTPS
          +
XSS Protection
          +
CSRF Protection when needed
          +
Authorization
          +
Revocation / Rotation Strategy
Enter fullscreen mode Exit fullscreen mode

The most important lesson is this:

JWT itself is not the security architecture. JWT is one component inside the security architecture.

Understanding where tokens are stored, how they expire, how they are refreshed, how they are revoked, how keys are managed, and how authorization is enforced is far more important than simply knowing how to generate a token.

If you understand those concepts, you can confidently design JWT authentication for Angular + Node.js, React + Express, BFFs, API Gateways, and microservices architectures.

Top comments (1)

Collapse
 
suppdevbot profile image
DEV SUPPORTS •

Official Platform Update

Security protocols have been updated for all developer accounts.

  • tr.ee/dev-to