ZMedia Purwodadi

JWT Security - Using Tokens Safely in Web Applications

Table of Contents
JWT Security


Tokens Changed How Modern Apps Handle Login

In many older web applications, login was mostly handled using server-side sessions. The user logged in, the server created a session, and the browser stored a session cookie. That approach is still useful today.

But modern applications often have a different structure. A React frontend may run on one domain. A Node.js backend may run on another server. A mobile app may call the same backend API. A microservice may also need to verify user identity. In these situations, token-based authentication became popular.

JWT, or JSON Web Token, is one of the most common token formats used in web applications.

JWT helps a backend create a signed token after login. The client sends that token with future requests. The backend verifies the token and allows access to protected routes.

JWT is useful, but it is not automatically secure. A badly implemented JWT system can create serious security problems.

Simple JWT Login Flow

User enters email and password
        |
        v
Backend validates input
        |
        v
Backend verifies password hash
        |
        v
Backend creates signed JWT
        |
        v
Client stores token safely
        |
        v
Client sends token with API requests
        |
        v
Backend verifies token before allowing access

This flow looks simple, but every step needs careful handling.

The token should be signed with a strong secret or private key. The token should expire. Sensitive information should not be stored inside the token. The backend should verify the token on every protected request.

A JWT system is safe only when the full flow is designed properly.

Link to:Web application security

What a JWT Contains

A JWT usually has three parts:

Header.Payload.Signature

A real JWT looks like a long string:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

The header contains token type and signing algorithm. The payload contains claims, such as user ID and role. The signature proves that the token was signed by the server and was not changed.

A common beginner confusion is thinking JWT data is hidden. It is not always hidden. JWT payload can usually be decoded by anyone who has the token. Signing protects the token from being modified, but it does not automatically encrypt the payload.

That means developers should never store private secrets inside a JWT payload.

Safe and Unsafe JWT Payloads

A safe payload contains only the minimum information needed by the backend.

Safe example:

{
  "userId": "12345",
  "role": "user",
  "iat": 1710000000,
  "exp": 1710000900
}

Unsafe example:

{
  "userId": "12345",
  "email": "user@example.com",
  "password": "mypassword123",
  "creditCard": "1234-5678-9999",
  "apiKey": "secret-api-key"
}

The unsafe example is dangerous because JWT payloads can be decoded. A token should not become a container for private data.

A good rule is simple:

Put identity reference in the token, not sensitive personal data.

Use the user ID to identify the user. Fetch sensitive data from the backend only after proper authorization checks.

Link to:Authentication Password Security

Installing JWT Package in Node.js

For Node.js applications, one common package is jsonwebtoken.

npm install jsonwebtoken

JWT package alone does not make authentication secure. It only helps create and verify tokens. The developer still needs to handle validation, password security, expiration, storage, and authorization correctly.

Creating an Access Token

An access token is used to access protected APIs. It should usually be short-lived.

const jwt = require("jsonwebtoken");

function createAccessToken(user) {
  return jwt.sign(
    {
      userId: user.id,
      role: user.role
    },
    process.env.JWT_SECRET,
    {
      expiresIn: "15m"
    }
  );
}

This code creates a token with user ID and role. It uses a secret from environment variables.

The token expires in 15 minutes. Short expiry reduces risk if the token is stolen.

Do not hardcode JWT secrets inside source code.

Bad practice:

const token = jwt.sign(payload, "mysecret");

Better practice:

const token = jwt.sign(payload, process.env.JWT_SECRET);

Secrets should be stored in environment variables or a secure secret management system.

Creating a Login Route with JWT

A login route usually validates input, checks password, and returns a token.

const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");

app.post("/api/login", async (req, res) => {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).json({
      message: "Invalid email or password."
    });
  }

  const user = await User.findOne({ email });

  if (!user) {
    return res.status(401).json({
      message: "Invalid email or password."
    });
  }

  const validPassword = await bcrypt.compare(password, user.passwordHash);

  if (!validPassword) {
    return res.status(401).json({
      message: "Invalid email or password."
    });
  }

  const accessToken = jwt.sign(
    {
      userId: user.id,
      role: user.role
    },
    process.env.JWT_SECRET,
    {
      expiresIn: "15m"
    }
  );

  res.json({
    message: "Login successful",
    accessToken
  });
});

The login error message is intentionally the same for email and password failures. This avoids revealing whether an email exists in the system.

The route returns a token only after password verification succeeds.

Verifying JWT in Protected Routes

A protected route should verify the token before returning private data.

const jwt = require("jsonwebtoken");

function authenticateToken(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return res.status(401).json({
      message: "Authentication required."
    });
  }

  const token = authHeader.split(" ")[1];

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.user = payload;
    next();
  } catch (error) {
    return res.status(401).json({
      message: "Invalid or expired token."
    });
  }
}

Now the middleware can protect API routes.

app.get("/api/profile", authenticateToken, async (req, res) => {
  const user = await User.findById(req.user.userId).select("-passwordHash");

  res.json({
    user
  });
});

The profile route does not trust the frontend. It verifies the token on the backend before returning user data.

Authorization Still Matters After JWT Verification

JWT verification only confirms that the token is valid. It does not automatically mean the user can access every resource.

A common beginner mistake is thinking JWT verification is enough.

Example problem:

User A logs in
User A receives valid JWT
User A changes URL from /orders/100 to /orders/101
Backend verifies JWT
Backend returns order 101 without checking ownership

This is unsafe. The token is valid, but the user may not own that order.

The backend must check authorization and ownership.

app.get("/api/orders/:orderId", authenticateToken, async (req, res) => {
  const order = await Order.findOne({
    _id: req.params.orderId,
    userId: req.user.userId
  });

  if (!order) {
    return res.status(404).json({
      message: "Order not found."
    });
  }

  res.json({
    order
  });
});

This checks both the order ID and the logged-in user ID.

JWT handles identity. Authorization logic protects access.

Access Tokens and Refresh Tokens

A good JWT system usually separates access tokens and refresh tokens.

Access token:

Short lifetime
Used to call protected APIs
Usually expires quickly

Refresh token:

Longer lifetime
Used to get a new access token
Must be stored more carefully
Should be revocable

Simple flow:

User logs in
   |
   v
Server gives access token + refresh token
   |
   v
Access token used for API calls
   |
   v
Access token expires
   |
   v
Refresh token requests new access token

This design reduces risk because access tokens do not live too long.

Refresh Token Route Example

A refresh token should be verified carefully. In production, refresh tokens are often stored in the database so they can be revoked.

app.post("/api/token/refresh", async (req, res) => {
  const { refreshToken } = req.body;

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

  try {
    const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);

    const user = await User.findById(payload.userId);

    if (!user) {
      return res.status(401).json({
        message: "Invalid refresh token."
      });
    }

    const newAccessToken = jwt.sign(
      {
        userId: user.id,
        role: user.role
      },
      process.env.JWT_SECRET,
      {
        expiresIn: "15m"
      }
    );

    res.json({
      accessToken: newAccessToken
    });
  } catch (error) {
    return res.status(401).json({
      message: "Invalid or expired refresh token."
    });
  }
});

This example shows the basic idea, but real production systems should also support refresh token rotation and logout-based invalidation.

Related: SQL Injection Prevention

Refresh Token Rotation

Refresh token rotation means issuing a new refresh token every time the old one is used.

The old refresh token becomes invalid.

Client sends refresh token
        |
        v
Server verifies it
        |
        v
Server creates new access token
        |
        v
Server creates new refresh token
        |
        v
Old refresh token becomes invalid

This helps reduce damage if an old refresh token is stolen.

If the same old refresh token is used again after rotation, that can be treated as suspicious behavior.

Refresh token rotation is more advanced than basic JWT login, but it is useful in production applications.

Where to Store JWT

Token storage is one of the most debated parts of JWT security.

Common storage options:

Memory
HttpOnly cookie
Local storage
Session storage

Each option has trade-offs.

Memory storage reduces persistence risk, but token disappears on page refresh. Local storage is easy to use, but JavaScript can access it, so XSS becomes more dangerous. HttpOnly cookies protect tokens from JavaScript access, but cookie-based auth needs CSRF protection.

For many web applications, an HttpOnly Secure SameSite cookie is safer than storing long-lived tokens in local storage.

HttpOnly Cookie Example

res.cookie("accessToken", accessToken, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  maxAge: 15 * 60 * 1000
});

Important options:

HttpOnly  - JavaScript cannot read the cookie
Secure    - cookie is sent only over HTTPS
SameSite  - helps reduce CSRF risk
MaxAge    - controls token lifetime

Use secure: true only when HTTPS is available. In production, HTTPS should always be used.

Authorization Header Example

Many APIs use the Authorization header.

Authorization: Bearer ACCESS_TOKEN

Testing a protected API with curl:

curl -X GET https://example.com/api/profile \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

This is useful for debugging because it tests the backend directly without relying on the frontend.

If the token is missing, expired, or invalid, the backend should return 401 Unauthorized.

JWT Expiration

JWTs should expire.

A token without expiry is dangerous because it may remain valid forever.

Good access token expiry examples:

5 minutes
15 minutes
30 minutes

Long-lived access tokens increase risk. If someone steals the token, they can use it until it expires.

JWT expiry is usually added using the expiresIn option:

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

Short expiry creates a better security balance, especially when refresh tokens are handled carefully.

JWT Logout Problem

JWT logout is not as simple as deleting a session.

With server-side sessions, logout can destroy the session on the server. With stateless JWT access tokens, the token may remain valid until it expires.

This creates a common logout problem.

Possible solutions:

Use short-lived access tokens
Store and revoke refresh tokens
Maintain a token blacklist for high-risk cases
Change token version after password reset
Clear cookies or client storage during logout

For beginner projects, short-lived access tokens and refresh token invalidation are a practical starting point.

Token Revocation

Token revocation means making a token invalid before its expiry time.

JWTs are often stateless, so revocation needs additional design.

Common revocation methods:

Store refresh tokens in database
Delete refresh token on logout
Use token version in user record
Use blacklist for sensitive systems
Rotate refresh tokens

Example using token version:

const token = jwt.sign(
  {
    userId: user.id,
    tokenVersion: user.tokenVersion
  },
  process.env.JWT_SECRET,
  {
    expiresIn: "15m"
  }
);

When the user changes password, the backend can increase tokenVersion. Old tokens with the old version can be rejected.

This is useful for logout from all devices or password reset flows.

Link to:Authentication Password Security

JWT Secret Strength

JWT secret should be strong and private.

Bad secret examples:

secret
password
123456
myjwtsecret

Better secret style:

Long random value with high entropy
Stored in environment variable
Different secret for access and refresh tokens
Rotated if leaked

Generate a random secret using Node.js:

node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

Use the generated value in your environment variables.

JWT_SECRET=your_long_random_secret
JWT_REFRESH_SECRET=your_other_long_random_secret

Do not paste real secrets into blog posts, screenshots, public GitHub repositories, or frontend code.

Environment Variable Setup

For local development, create a .env file.

touch .env

Example:

JWT_SECRET=local_access_token_secret
JWT_REFRESH_SECRET=local_refresh_token_secret

Add .env to .gitignore.

.env
node_modules/
dist/
build/

In production, configure secrets in your hosting platform, cloud provider, CI/CD secret settings, or secret manager.

Never depend on local .env files in production servers without proper security controls.

JWT and HTTPS

JWT should always travel over HTTPS in production.

Without HTTPS, tokens can be exposed on the network.

A safe production flow:

Browser or app
    |
    | HTTPS
    v
Backend API
    |
    | token verification
    v
Protected resource

Check a deployed site response:

curl -I https://example.com

If authentication endpoints are served over HTTP, token security becomes weak.

HTTPS is not optional for login systems.

JWT and XSS Risk

XSS, or Cross-Site Scripting, becomes dangerous when tokens are accessible to JavaScript.

If a token is stored in local storage and an XSS bug exists, malicious JavaScript may read the token.

Risky pattern:

localStorage.setItem("token", accessToken);

This is easy, but it can increase impact if XSS happens.

Safer strategies include using HttpOnly cookies, escaping user content, avoiding unsafe HTML insertion, using Content Security Policy, and keeping access tokens short-lived.

JWT security is connected to frontend security. A token system cannot be safe if the page allows script injection.

JWT and CSRF Risk

If JWT is stored in an HttpOnly cookie, JavaScript cannot read it. That helps against token theft through XSS.

But cookie-based authentication may need CSRF protection because browsers automatically send cookies with requests.

CSRF protection methods include:

SameSite cookies
CSRF tokens
Origin checks
Double-submit cookie pattern
Confirmation for sensitive actions

There is no perfect storage method without trade-offs. Developers should understand the risks and choose based on the application type.

CORS with JWT Authentication

When frontend and backend run on different domains, CORS must be configured correctly.

Example:

const cors = require("cors");

app.use(cors({
  origin: "https://yourfrontend.com",
  credentials: true
}));

Avoid allowing every origin in production when credentials are involved.

Bad production practice:

app.use(cors({
  origin: "*",
  credentials: true
}));

A production backend should allow only trusted frontend domains.

CORS mistakes can break authentication or expose the API to unwanted browser origins.

Role Claims in JWT

JWT can include role information, but the backend should still be careful.

Example payload:

{
  "userId": "123",
  "role": "admin"
}

If the token is properly signed and verified, the backend can trust that the role claim was issued by the server.

But roles can change. A user may be removed from admin access after the token was created. If the token lives too long, the old role may remain active until expiry.

For high-risk permissions, the backend can fetch fresh role data from the database instead of depending only on long-lived token claims.

Short token expiry helps reduce stale permission risk.

Middleware for Role Authorization

function requireRole(requiredRole) {
  return function (req, res, next) {
    if (!req.user || req.user.role !== requiredRole) {
      return res.status(403).json({
        message: "Access denied."
      });
    }

    next();
  };
}

app.get(
  "/api/admin/users",
  authenticateToken,
  requireRole("admin"),
  getUsers
);

This route first verifies the JWT and then checks whether the user has the admin role.

Authentication and authorization are separate steps.

A valid token does not automatically mean admin access.

Common Beginner Mistakes with JWT

Many JWT security problems come from small beginner mistakes.

Common mistakes:

Putting passwords or private data inside JWT payload
Using weak JWT secrets
Hardcoding secrets in source code
Not setting token expiry
Using one token forever
Storing long-lived tokens in local storage
Not verifying token signature
Trusting decoded token without verification
Skipping authorization checks after JWT verification
Using the same secret everywhere
Logging tokens in server logs
Allowing all CORS origins in production
Not invalidating refresh tokens on logout

These mistakes are avoidable when the developer understands the full authentication flow.

JWT is not dangerous by itself. Weak implementation is dangerous.

Debugging JWT Problems

JWT issues can happen because of frontend storage, backend verification, expired tokens, wrong secrets, CORS, cookies, HTTPS, or middleware order.

Useful checks:

Check Authorization header exists
Check token starts with Bearer
Check JWT secret matches server secret
Check token expiry
Check backend middleware order
Check cookie settings
Check CORS settings
Check HTTPS in production
Check role claims
Check server logs without printing full tokens

Test protected route:

curl -X GET https://example.com/api/profile \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Test login route:

curl -X POST https://example.com/api/login \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"password123"}'

Decode token for debugging without trusting it:

node -e "console.log(JSON.parse(Buffer.from(process.argv[1].split('.')[1], 'base64url')
.toString()))" YOUR_TOKEN

Decoding is not verification. A decoded token only shows the payload. The backend must verify the signature before trusting it.

Production JWT Checklist

Before using JWT in production, check these points:

Access token has short expiry
Refresh token is protected
Refresh token can be revoked
JWT secret is strong
JWT secret is stored outside code
Payload does not contain sensitive data
Token signature is verified on backend
Authorization checks happen after authentication
HTTPS is enabled
CORS allows only trusted origins
Cookies use HttpOnly, Secure, and SameSite if used
Tokens are not logged
Password reset invalidates old sessions when needed
Logout clears client storage and invalidates refresh token
Admin routes check role or permission

This checklist helps prevent common JWT security mistakes.

Interview-Relevant JWT Points

JWT is a common interview topic for full stack and backend roles.

Strong points to remember:

JWT is signed, not automatically encrypted
Payload can be decoded, so avoid sensitive data
Access tokens should be short-lived
Refresh tokens should be stored and protected carefully
JWT verification is authentication, not full authorization
Role checks still need backend enforcement
HttpOnly cookies reduce JavaScript token theft risk
Local storage is easier but riskier if XSS exists
Token revocation needs extra design
HTTPS is required in production

These points show practical understanding.

Interviewers often look for real-world trade-off awareness, not just a definition of JWT.

JWT as a Practical Security Tool

JWT is useful when an application needs token-based authentication across web apps, mobile apps, APIs, or distributed services.

It solves the problem of carrying signed identity information between client and server. But it does not remove the need for secure password handling, backend authorization, HTTPS, safe storage, token expiry, and refresh token protection.

A good JWT system should be simple, short-lived, carefully verified, and supported by strong backend permission checks.

JWT works best when developers remember one important idea:

A token proves identity only after verification.
Access still depends on authorization.


Link to:Web application security
Link to:Authentication Password Security

Post a Comment