DEV Community

Deepak Kumar
Deepak Kumar

Posted on Originally published at jsonformatterhub.com

JSON Web Tokens (JWT) Explained — Structure, Use Cases, and Security

A JSON Web Token (JWT) is a compact, URL-safe way to transmit claims between two parties. It's used almost universally for API authentication — when a user logs in, the server issues a JWT; the client stores it and sends it with every subsequent request. The server verifies the token's signature without looking anything up in a database. Understanding exactly how JWTs work — and where they go wrong — is essential for any developer building an API.

The Three-Part Structure

A JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNzE1MzM2MDAwLCJleHAiOjE3MTUzMzk2MDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Enter fullscreen mode Exit fullscreen mode

Three base64url-encoded sections, separated by dots: header.payload.signature.

Part 1: Header

Decode the first section and you get a JSON object describing the token type and signing algorithm:

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

Common algorithms: HS256 (HMAC-SHA256, symmetric — one shared secret), RS256 (RSA-SHA256, asymmetric — private key signs, public key verifies).

Part 2: Payload (Claims)

The payload carries the actual data — called claims:

{
  "sub": "1234567890",
  "name": "Alice",
  "email": "alice@example.com",
  "role": "admin",
  "iat": 1715336000,
  "exp": 1715339600,
  "iss": "https://auth.example.com",
  "aud": "https://api.example.com"
}
Enter fullscreen mode Exit fullscreen mode

Standard registered claims:

  • sub — subject (usually a user ID)
  • iat — issued at (Unix timestamp)
  • exp — expiration time (Unix timestamp)
  • iss — issuer (who created the token)
  • aud — audience (who should accept the token)
  • nbf — not before (token is invalid before this time)

Part 3: Signature

The signature is computed over the encoded header and payload using the algorithm and secret:

HMACSHA256(
  base64url(header) + "." + base64url(payload),
  secret
)
Enter fullscreen mode Exit fullscreen mode

The signature cannot be forged without the secret. If the payload is tampered with, the signature won't match and the token is rejected. Note: the payload is encoded, not encrypted — anyone can read it by base64-decoding it.

How to Decode a JWT (Without Verifying)

To inspect a token's payload for debugging (don't rely on this for authorization):

// JavaScript — decode without verifying signature
function decodeJWT(token) {
  const parts = token.split('.');
  if (parts.length !== 3) throw new Error('Invalid JWT');
  const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
  return JSON.parse(atob(payload));
}

const claims = decodeJWT('eyJhbGci...');
console.log(claims.sub); // user ID
console.log(new Date(claims.exp * 1000)); // expiry date
Enter fullscreen mode Exit fullscreen mode

Creating and Verifying JWTs

JavaScript (jsonwebtoken library):

const jwt = require('jsonwebtoken');

const SECRET = process.env.JWT_SECRET; // store in env, never hardcode

// Sign a token (expires in 1 hour)
const token = jwt.sign(
  { sub: user.id, name: user.name, role: user.role },
  SECRET,
  { expiresIn: '1h', issuer: 'https://auth.example.com' }
);

// Verify and decode
try {
  const payload = jwt.verify(token, SECRET, {
    issuer: 'https://auth.example.com'
  });
  console.log(payload.sub);
} catch (err) {
  // TokenExpiredError, JsonWebTokenError, NotBeforeError
  console.error('Invalid token:', err.message);
}
Enter fullscreen mode Exit fullscreen mode

Python (PyJWT library):

import jwt
from datetime import datetime, timedelta, timezone

SECRET = 'your-secret-key'

# Sign
payload = {
    'sub': str(user.id),
    'name': user.name,
    'iat': datetime.now(timezone.utc),
    'exp': datetime.now(timezone.utc) + timedelta(hours=1)
}
token = jwt.encode(payload, SECRET, algorithm='HS256')

# Verify
try:
    decoded = jwt.decode(token, SECRET, algorithms=['HS256'])
    print(decoded['sub'])
except jwt.ExpiredSignatureError:
    print('Token expired')
except jwt.InvalidTokenError as e:
    print(f'Invalid token: {e}')
Enter fullscreen mode Exit fullscreen mode

HS256 vs RS256: Which to Use

Algorithm Type Signs with Verifies with Best for
HS256 Symmetric Shared secret Same secret Single-service apps where server signs and verifies
RS256 Asymmetric Private key Public key Microservices — auth server signs, multiple services verify without the private key
ES256 Asymmetric (ECDSA) Private key Public key Same as RS256 but smaller key size

Security Pitfalls

The "alg: none" attack

Early JWT libraries accepted tokens with "alg": "none" and no signature, treating them as valid. Always explicitly specify which algorithms you accept when verifying — never pass an empty or wildcard list:

// WRONG — accepts any algorithm including "none"
jwt.verify(token, secret);

// CORRECT — whitelist the expected algorithm
jwt.verify(token, secret, { algorithms: ['HS256'] });
Enter fullscreen mode Exit fullscreen mode

localStorage vs httpOnly cookie

Storing a JWT in localStorage makes it readable by any JavaScript on the page, including injected scripts (XSS). Storing it in an httpOnly cookie means JavaScript can't read it, but you must also set SameSite=Strict or SameSite=Lax to prevent CSRF. For most applications, httpOnly cookies are safer.

Keep expiry short, use refresh tokens

A stolen JWT can't be invalidated (JWTs are stateless). Short-lived access tokens (15 minutes to 1 hour) combined with longer-lived refresh tokens minimize the damage window.

Validate iss and aud claims

Always verify iss (issuer) and aud (audience) in addition to the signature and expiry. A token issued by one service shouldn't be accepted by another service even if the signature is valid.

What NOT to store in a JWT payload

The payload is base64-encoded, not encrypted — anyone with the token can read it. Never put passwords, credit card numbers, social security numbers, or other sensitive PII in the payload.

Complete Authentication Flow with JWT

  1. User submits credentials — the client sends username and password to POST /auth/login.
  2. Server validates credentials — checks the password hash in the database.
  3. Server issues tokens — returns a short-lived access token (15–60 min) and a long-lived refresh token (7–30 days).
  4. Client stores tokens — access token in memory or a session variable; refresh token in an httpOnly cookie.
  5. Client sends the access token — every API request includes Authorization: Bearer <access_token>.
  6. Server verifies the token — checks signature, expiry, issuer, and audience. No database call needed.
  7. Access token expires — the client sends the refresh token to POST /auth/refresh to get a new access token.
  8. User logs out — the client discards the access token; the server invalidates the refresh token in a revocation store.

The Refresh Token Pattern

const jwt = require('jsonwebtoken');

const ACCESS_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_SECRET = process.env.REFRESH_TOKEN_SECRET;

function issueTokens(userId, role) {
  const accessToken = jwt.sign(
    { sub: userId, role },
    ACCESS_SECRET,
    { expiresIn: '15m', issuer: 'https://auth.example.com' }
  );

  const refreshToken = jwt.sign(
    { sub: userId },
    REFRESH_SECRET,
    { expiresIn: '7d', issuer: 'https://auth.example.com' }
  );

  return { accessToken, refreshToken };
}

// Refresh endpoint
app.post('/auth/refresh', (req, res) => {
  const token = req.cookies.refreshToken; // httpOnly cookie
  if (!token) return res.status(401).json({ error: 'No refresh token' });

  try {
    const payload = jwt.verify(token, REFRESH_SECRET, {
      issuer: 'https://auth.example.com'
    });
    const { accessToken } = issueTokens(payload.sub, payload.role);
    res.json({ accessToken });
  } catch (err) {
    res.status(403).json({ error: 'Invalid or expired refresh token' });
  }
});
Enter fullscreen mode Exit fullscreen mode

JWT Revocation Strategies

JWTs are stateless by design — there's no built-in way to invalidate a token before it expires. Practical patterns used in production:

  • Short expiry — the simplest strategy. If access tokens expire in 15 minutes, a stolen token is only useful for 15 minutes at most.
  • Refresh token revocation list — store refresh token IDs (the jti claim) in Redis or a database, checked on each refresh. Log out by adding the ID to the revocation list.
  • Token rotation — issue a new refresh token on every use and invalidate the old one. If a stolen token is used, the legitimate user's next request fails, alerting the system.
  • Version claim — store a tokenVersion integer on the user record. Embed it in the JWT, verify on each request, increment to invalidate all existing tokens for that user.

Common JWT Mistakes

Mistake Risk Fix
Hardcoding the secret in source code Secret leaked in git history Load from environment variable or secrets manager
Using a weak or short secret for HS256 Brute-forced offline Use a 256-bit+ random secret; prefer RS256 for distributed systems
Not verifying exp Expired tokens accepted forever Always verify — most libraries do this by default
Not verifying iss and aud Token from another service accepted Pass expected issuer and audience to the verify call
Storing sensitive data in payload Payload readable by anyone with the token Store only user ID and role; fetch sensitive data server-side
Storing access token in localStorage Exposed to XSS attacks Keep access token in memory; refresh token in httpOnly cookie

If you're debugging a token right now, the JWT Decoder lets you paste one in and see the header, payload, and expiry status instantly, entirely in your browser.

Top comments (0)