Skip to main content
Toolsbase Logo

JWT Structure, Security Risks, and Best Practices

Toolsbase Editorial Team
JWTRFC 7519OWASPAuthenticationSecurityWeb Development

What Makes JWT Tricky

JWT (JSON Web Token) looks approachable but is easy to misuse.

Teams adopt JWT for "stateless authentication," then discover their payloads contain personally identifiable information visible to any client, their parser accepts alg: none tokens, or their logout flow is cosmetic because the token remains valid until it expires. These aren't edge cases — they surface regularly in production systems.

This guide covers the precise structure defined in RFC 7519 (JWT), RFC 7515 (JWS), and RFC 7516 (JWE), along with the practical security decisions that matter.


The Three Parts of a JWT

A JWT is three Base64url-encoded strings joined by dots (.).

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Decoded:

1. Header

{
  "alg": "HS256",
  "typ": "JWT"
}

alg specifies the signing algorithm. typ is typically "JWT".

Important: The Header is Base64url encoded — not encrypted. Anyone can decode it.

2. Payload

{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "exp": 1516242622,
  "iss": "https://auth.example.com",
  "aud": "https://api.example.com"
}

Registered claims (RFC 7519 Section 4.1):

Claim Description
iss Issuer — entity that issued the token
sub Subject — the principal (usually a user ID)
aud Audience — intended recipient(s)
exp Expiration Time — Unix timestamp after which the token is invalid
nbf Not Before — token is not valid before this time
iat Issued At — token creation time
jti JWT ID — unique identifier for the token

The Payload is also not encrypted (in JWS). Anyone who has the token can decode it by Base64url decoding the middle segment.

3. Signature

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

The signature enables tamper detection. A valid signature means: "this token was issued by the party holding the signing key, and neither the header nor the payload has been modified since."

Use the JWT Decoder to inspect any JWT.


JWS vs JWE

The JWT ecosystem has two protection modes:

Spec What It Protects Payload Visibility
JWS (RFC 7515) Integrity (tamper detection) Visible to anyone
JWE (RFC 7516) Integrity + Confidentiality Encrypted, opaque

When developers say "JWT," they almost always mean JWS. JWE encrypts the payload, but its complexity makes it a niche choice. The more practical guidance: don't put secrets in JWTs at all.


HS256 vs RS256: Choosing a Signing Algorithm

HS256 (HMAC-SHA256)

A symmetric algorithm: the same secret key is used for both generation and verification.

Sign:   HMACSHA256(header.payload, secret_key)
Verify: Recompute HMACSHA256(header.payload, secret_key) and compare

Problem: Every service that verifies tokens must hold the same secret key. In a microservices architecture, sharing a secret key across services creates a broad attack surface.

RS256 (RSA-SHA256)

An asymmetric algorithm: the private key signs tokens; the public key verifies them.

Sign   (auth server only): RSA-sign(header.payload, private_key)
Verify (all API servers):   RSA-verify(header.payload, signature, public_key)

Advantage: The public key is, literally, public. API servers never hold the private key, so a compromised API server cannot forge new tokens.

const jwt = require('jsonwebtoken');
const fs = require('fs');

const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');

// Issue a token (auth server)
const token = jwt.sign(
  { sub: 'user-123' },
  privateKey,
  {
    algorithm: 'RS256',
    expiresIn: '15m',
    issuer: 'https://auth.example.com',
    audience: 'https://api.example.com',
  }
);

// Verify (API server — public key only)
try {
  const decoded = jwt.verify(token, publicKey, {
    algorithms: ['RS256'],  // explicit allowlist — never include 'none'
    issuer: 'https://auth.example.com',
    audience: 'https://api.example.com',
  });
  console.log(decoded.sub); // 'user-123'
} catch (err) {
  // TokenExpiredError, JsonWebTokenError, NotBeforeError
  console.error('Token rejected:', err.message);
}

Algorithm Recommendations

Algorithm Type Recommendation Use Case
RS256 RSA + SHA-256 ✓ Recommended Multiple services, microservices
ES256 ECDSA + P-256 ✓ Recommended Shorter keys than RS256, same security
HS256 HMAC-SHA256 △ Limited Single service, internal only
RS512 / ES512 Larger key △ Situational High-security requirements

The alg: none Vulnerability

One of the best-known JWT vulnerabilities, rooted in RFC 7519 Section 6, which acknowledges that JWTs with alg: none carry no integrity protection.

Several real-world JWT libraries historically accepted the following attack:

  1. Take a valid token and decode the Header and Payload
  2. Replace the Header with {"alg": "none", "typ": "JWT"}
  3. Modify the Payload: {"sub": "admin", "role": "superadmin", ...}
  4. Reconstruct the token with an empty Signature segment
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJzdXBlcmFkbWluIn0.

Mitigation: always specify an explicit algorithm allowlist:

// ❌ Dangerous: algorithm not constrained
jwt.verify(token, publicKey);

// ✅ Safe: explicit allowlist, 'none' excluded
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
# Python (PyJWT)
import jwt

# ❌ Dangerous
decoded = jwt.decode(token, public_key)

# ✅ Safe
decoded = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],
    options={"require": ["exp", "iss", "aud"]}
)

Never Put Secrets in Payloads

The Payload is decoded with a trivial one-liner:

function decodePayload(token) {
  const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
  return JSON.parse(atob(base64));
}

// If the token is in localStorage, any JavaScript on the page can read it

Never include in payloads:

  • Passwords, API keys, private keys
  • PII: email addresses, phone numbers, physical addresses
  • Financial data: card numbers, account balances
  • Any value you would not store in a URL parameter

Appropriate payload content:

  • User ID (a reference, not the full user record)
  • Roles ("admin", "editor")
  • Scopes ("read:articles", "write:orders")
  • Standard time claims (exp, iat, nbf)

Stateless Authentication Has Real Limitations

JWT's "stateless" property means the server verifies the token without a database lookup. This is its selling point — and its constraint.

Stateless (JWT):
Client → [JWT] → Server → verify signature → done
(no database query)

Stateful (Session):
Client → [Session ID] → Server → query DB for session → done

The Immediate Revocation Problem

If a user changes their password, an admin disables an account, or a token is compromised, you cannot invalidate an issued JWT before it expires in a purely stateless model. If the access token expires in one hour, the compromised token is valid for up to one hour.

Strategies:

1. Short access token lifetime + refresh token rotation

// Access token: 15 minutes
const accessToken = jwt.sign({ sub: userId }, privateKey, {
  algorithm: 'RS256',
  expiresIn: '15m',
});

2. Token version field in the user record

// Add token_version to the user record
// Increment on password change, account disable, etc.

async function verifyToken(token) {
  const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

  const user = await db.users.findById(decoded.sub);
  if (user.token_version !== decoded.token_version) {
    throw new Error('Token has been revoked');
  }

  return decoded;
}

This sacrifices pure statelessness for correctness — one database query per request.

3. JTI denylist (blocklist)

Store revoked JWT IDs (the jti claim) in Redis with TTL matching the token's remaining lifetime. This also requires a lookup per request, but the lookup is a fast key-value read.


Refresh Token Design

The standard pattern separates short-lived access tokens (JWTs) from long-lived refresh tokens (opaque random values stored in the database).

Access token:  JWT, 15–30 minutes, stateless verification
Refresh token: random bytes, 7–30 days, stored in DB as hash
const crypto = require('crypto');

async function issueTokenPair(userId) {
  const accessToken = jwt.sign(
    { sub: userId, type: 'access' },
    privateKey,
    { algorithm: 'RS256', expiresIn: '15m' }
  );

  // Refresh token is NOT a JWT — it's a random opaque value
  const refreshToken = crypto.randomBytes(32).toString('hex');

  // Store hash of refresh token (not the token itself)
  await db.refreshTokens.insert({
    hash: crypto.createHash('sha256').update(refreshToken).digest('hex'),
    user_id: userId,
    expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
  });

  return { accessToken, refreshToken };
}

async function rotateRefreshToken(incomingToken) {
  const hash = crypto.createHash('sha256').update(incomingToken).digest('hex');
  const record = await db.refreshTokens.findOne({ hash });

  if (!record || record.expires_at < new Date()) {
    throw new Error('Invalid or expired refresh token');
  }

  // Rotate: delete old token and issue new pair
  await db.refreshTokens.delete({ hash });
  return issueTokenPair(record.user_id);
}

Why not make the refresh token a JWT? Because refresh tokens need to be immediately revocable when a device is lost or a session is explicitly terminated. Storing them in the database enables this.


Security Checklist

Based on the OWASP JWT Cheat Sheet:

  • Specify algorithms allowlist explicitly — never include "none"
  • Always set exp and validate it on every request
  • Validate iss (issuer) and aud (audience) claims
  • Keep the access token lifetime at 15–30 minutes maximum
  • Never include secrets or PII in the payload
  • Rotate refresh tokens on every use
  • Transmit tokens only over HTTPS
  • Prefer httpOnly cookies over localStorage for browser storage (XSS mitigation)
  • Configure JWK Set URL (jwks_uri) for automatic public key rotation (RS256/ES256)
  • Use constant-time comparison when checking signatures or token hashes

Summary

JWT done right is a powerful authentication mechanism. The key points:

  1. The payload is public — use JWE if confidentiality is required
  2. Always specify the algorithm explicitly — prevent alg: none attacks
  3. Short access tokens + rotating refresh tokens — balance usability and security
  4. Immediate revocation requires a database lookup — accept that trade-off when needed
  5. RS256 or ES256 over HS256 — asymmetric keys are safer in multi-service architectures

Decode and inspect any JWT with the JWT Decoder.


References