Skip to main content

Command Palette

Search for a command to run...

JWT: What's Actually Inside the Token

Updated
9 min readView as Markdown
JWT: What's Actually Inside the Token

Series: System Design · APIs & Communication — Pillar 3 of 8 This pillar: 00 — Overview · 01 — API Design · 02 — REST APIs · 03 — Authentication vs Authorisation · 04 — Session vs Token Auth · 05 — OAuth & OpenID Connect · 06 — JWT · 07 — WebSockets · 08 — Long Polling, SSE & Webhooks · 09 — Sync vs Async Communication · 10 — API Gateways · 11 — Wrap-up


JWT: What's Actually Inside the Token

Systems Design

# Post What it covers
00 APIs & Communication: How Services Talk to Each Other How services talk to each other shapes everything about a system. Nine concepts covering REST, WebSockets, async patterns, and API gateways. (146 chars)
01 API Design: Building Contracts That Last A great API is a contract that outlasts your code. Here are the principles that make APIs intuitive to consume, safe to evolve, and cheap to maintain. (154 chars)
02 REST APIs: Constraints That Create Benefits REST isn't just HTTP with JSON. It's an architectural style with specific constraints — and understanding them explains why REST APIs are designed the way they are. (166 chars)
03 Authentication vs Authorisation: Two Questions, Two Checks Authentication is who you are. Authorisation is what you're allowed to do. Confusing them is one of the most common security mistakes in system design. (153 chars)
04 Session vs Token Authentication: Stateful vs Stateless Identity Session auth stores identity on the server. Token auth encodes it in the token. Here's how each works, where each breaks, and how to choose. (144 chars)
05 OAuth 2.0 & OpenID Connect: Delegated Access and Federated Identity OAuth 2.0 lets users grant apps access without sharing passwords. OpenID Connect adds identity on top. Here's how both actually work. (137 chars)
06 JWT: What's Actually Inside the Token ← you are here JWTs are everywhere in modern auth — and frequently misused. Here's exactly what a JWT contains, how the signature works, and what it doesn't protect. (153 chars)
07 WebSockets: Real-Time Bidirectional Communication HTTP is request-response. WebSockets are a persistent two-way channel. Here's how they work, when to use them, and what to watch out for at scale. (151 chars)
08 Long Polling, SSE & Webhooks: The Server-Push Spectrum Three patterns for server-push communication — long polling, server-sent events, and webhooks. Here's how each works and when to reach for each. (150 chars)
09 Sync vs Async Communication: The Architectural Fork Synchronous services couple tightly. Asynchronous services decouple — but add complexity. Here's how to reason about which your system needs. (147 chars)
10 API Gateways: One Entry Point, Every Cross-Cutting Concern An API gateway centralises auth, rate limiting, routing, and observability for all your services. Here's what it does, how it works, and when you need one. (158 chars)
11 APIs & Communication: Wrap-Up A complete recap of all ten API and communication concepts — REST, auth, JWT, WebSockets, webhooks, async patterns, and API gateways — and how they connect. (161 chars)

Custom claims (role, permissions, organisation) sit alongside standard claims in the same payload. No special namespacing is required, but using a domain namespace (https://sho.rt/role) prevents collisions with future standard claims.

Signature

The signature is computed over the base64url-encoded header and payload:

RS256: signature = RSA_SIGN(
  private_key,
  SHA256(base64url(header) + "." + base64url(payload))
)

HS256: signature = HMAC_SHA256(
  shared_secret,
  base64url(header) + "." + base64url(payload)
)

Verifying the signature:

  • RS256 (asymmetric): the issuer signs with their private key; any party with the public key can verify. The private key never leaves the issuer. Suitable for tokens that multiple services need to verify.
  • HS256 (symmetric): both issuer and verifier share the same secret. Any party with the secret can both issue and verify tokens. Simpler, but requires the secret to be shared with every service that needs to verify tokens.

For a microservices architecture, RS256 is strongly preferred: the auth service holds the private key; every other service has only the public key and can verify but not forge tokens.


What JWT validation looks like in practice

A correct JWT validation sequence:

def validate_token(token: str) -> dict:
    # 1. Decode header (don't trust the alg claim yet)
    header = decode_header(token)

    # 2. CRITICAL: verify alg is one of your allowed algorithms
    # Never accept "none" or algorithms you didn't intend to support
    if header["alg"] not in ALLOWED_ALGORITHMS:
        raise InvalidToken("Unsupported algorithm")

    # 3. Fetch the appropriate public key (by kid if using key rotation)
    public_key = get_public_key(header.get("kid"))

    # 4. Verify signature using YOUR chosen algorithm
    # Use a well-maintained library - never implement crypto yourself
    payload = jwt_library.decode(
        token,
        public_key,
        algorithms=ALLOWED_ALGORITHMS,  # Explicit - never trust header alone
        audience="https://api.sho.rt",
        issuer="https://auth.sho.rt"
    )

    # 5. Validate standard claims
    # (Good libraries do this automatically)
    assert payload["exp"] > current_time()
    assert payload["nbf"] <= current_time()
    assert payload["iss"] == "https://auth.sho.rt"
    assert payload["aud"] == "https://api.sho.rt"

    return payload

Common JWT vulnerabilities

The alg: none attack

The JWT specification allows alg: none — a token with no signature. Some early implementations read the algorithm from the token header and used it for validation, allowing an attacker to:

  1. Take any JWT
  2. Modify the payload to claim any identity
  3. Set alg: none in the header
  4. Remove the signature
  5. Submit the token

The server, following the alg: none instruction, skips signature verification and accepts the token.

Mitigation: always specify the allowed algorithms explicitly in your validation code. Never accept alg: none. Never use the algorithm from the token header to determine how to validate it — use the algorithm you issued the token with.

Algorithm confusion (RS256 to HS256)

A subtle variant: some libraries switch between asymmetric (RS256) and symmetric (HS256) validation based on the key type passed. An attacker who knows your public key (often published at a /.well-known/jwks.json endpoint) can:

  1. Sign a forged token using your public key as the HMAC secret
  2. Set alg: HS256 in the header
  3. Submit the token to a server expecting RS256 that misconfigures HS256 validation using the public key as the secret

The server, thinking it's doing HS256 validation, uses the public key as the HMAC secret — which matches because the attacker used the same key.

Mitigation: again, specify allowed algorithms explicitly. If you issue RS256 tokens, reject HS256 tokens entirely.

Sensitive data in the payload

The JWT payload is base64url-encoded, not encrypted. Decoding it requires zero cryptographic knowledge:

echo "eyJzdWIiOiI0ODIxIn0" | base64 -d
{"sub":"4821"}

Any data in the JWT payload is visible to anyone who receives or intercepts the token: the user's browser, browser extensions, intermediary proxies, and log aggregation systems.

Mitigation: never put sensitive data in a JWT payload — passwords, PII beyond what's necessary for routing, financial data, health information. For cases where encrypted claims are genuinely needed, use JWE (JSON Web Encryption) — a related standard that encrypts the payload.

Long expiry without refresh tokens

A JWT with a 30-day expiry that is stolen grants an attacker 30 days of access with no way to revoke it. The token is cryptographically valid regardless of whether the user has "logged out."

Mitigation: short-lived access tokens (15 minutes to 1 hour) with longer-lived refresh tokens that can be revoked server-side. Log out invalidates the refresh token; the access token remains valid until expiry, but that window is short enough to be acceptable.

JWT as a session replacement without revocation

Organisations sometimes replace session stores with JWTs to avoid the operational overhead of maintaining a session store. This works until you need to revoke a token — for account compromise, logout-all-devices, or account suspension. With no revocation mechanism, you can't.

Mitigation: maintain a token denylist (a Redis set of revoked JTI values) checked on every validation, or use refresh token rotation with server-side storage of valid refresh tokens. The denylist is small if access tokens are short-lived — you only need to track tokens that were revoked before natural expiry.


Key rotation

Signing keys should be rotated periodically. The standard pattern:

jwks.json (public key endpoint):
{
  "keys": [
    {"kid": "2026-01", "alg": "RS256", "n": "...", "e": "AQAB"},  ← current
    {"kid": "2025-12", "alg": "RS256", "n": "...", "e": "AQAB"}   ← previous (for tokens issued before rotation)
  ]
}

JWTs carry a kid (key ID) header claim. Validators fetch the appropriate public key by kid from the JWKS endpoint. The previous key is retained until all tokens issued with it have expired. Rotation is seamless from the consumer's perspective.


The tradeoffs

JWT vs opaque tokens. JWTs are stateless — no lookup required to validate. Opaque tokens require a token introspection call to the auth server. JWTs scale better but are harder to revoke. Opaque tokens are instantly revocable but add a network hop to every validation. For high-throughput internal validation, JWTs win. For use cases requiring instant revocation, opaque tokens or a denylist are necessary.

Payload size vs statefulness. The more claims encoded in a JWT, the larger it is — and it travels in every request header. Large JWTs have measurable impact at scale. Encode only what services need to route and make decisions without additional lookups: user ID, role, and tenant ID are usually enough. Avoid encoding data that changes frequently (subscription state, feature flags) since the token is issued at login and may be stale by the time it's used.


The one thing to remember

A JWT proves the payload was issued by a trusted party and hasn't been tampered with. It doesn't encrypt the payload, can't revoke itself, and is only as secure as your validation code. Always specify allowed algorithms explicitly, never put sensitive data in the payload, use short expiry times combined with revocable refresh tokens, and use a well-maintained library for validation — never roll your own JWT verification logic.


← Previous: OAuth 2.0 & OpenID Connect — token auth handles first-party identity; OAuth handles the case where a user wants to grant a third-party access to their resources without sharing their credentials.

→ Next: WebSockets — the auth posts covered how services verify identity; WebSockets cover how clients and servers communicate in real time when HTTP's request/response model isn't enough.

Systems Design

Part 37 of 50

Understanding these system design concepts is essential for architects, developers, and engineers to create scalable, reliable, and maintainable software systems that meet the needs of businesses.

Up next

WebSockets: Real-Time Bidirectional Communication

Systems Design # Post What it covers 00 APIs & Communication: How Services Talk to Each Other How services talk to each other shapes everything about a system. Nine concepts covering REST, WebSo