ekofyi
API Reverse Engineering8 min read

What your JWT tokens reveal about your backend

JWTs are meant to be opaque to users. They're not. Here's what I learn about your architecture just by decoding one.

JWTs aren't encrypted by default — they're base64-encoded. Anyone with a token can decode it and read the payload. Most developers know this in theory but still put way too much in there.

Here's a real token I intercepted (sanitized). Let me show you what I extract from it in under a minute.

Anatomy of a leaked JWT

text
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzE3MzgiLCJlbWFpbCI6ImFkbWluQGFjbWUuaW8iLCJyb2xlIjoiYWRtaW4i
LCJ0ZW5hbnRfaWQiOiJ0XzQ0MiIsInBlcm1pc3Npb25zIjpbInJlYWQiLCJ3cml0ZSIsImRlbGV0
ZSIsImFkbWluOnVzZXJzIl0sImlzcyI6ImF1dGgucHJvZC51cy1lYXN0LTEuYWNtZS5pbnRlcm5h
bCIsImlhdCI6MTcwNDA2NzIwMCwiZXhwIjoxNzM1Njg5NjAwfQ.
<signature>

Decoded payload:

json
{
  "sub": "user_1738",
  "email": "admin@acme.io",
  "role": "admin",
  "tenant_id": "t_442",
  "permissions": ["read", "write", "delete", "admin:users"],
  "iss": "auth.prod.us-east-1.acme.internal",
  "iat": 1704067200,
  "exp": 1735689600
}

From this single token, I now know 7 things about the backend. Let me break them down.

🔍 Finding #1: The signing algorithm

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

HS256 = HMAC with SHA-256. This means a shared secret is used for both signing and verification. Every service that validates this token has the same secret key. If any one of those services is compromised, I can forge tokens for all of them.

AlgorithmWhat it tells meAttack surface
HS256Shared secret across servicesBrute-force weak secrets, key reuse
RS256Public/private key pairKey confusion attacks, JWKS endpoint
noneNo signature validationForge any token freely
ES256ECDSA — mature setupLimited (good sign)

First thing I try with HS256: common weak secrets. You'd be surprised how often secret, password, or the company name works:

python
import jwt

payload = {'sub': 'admin', 'role': 'superadmin', 'exp': 9999999999}
for secret in ['secret', 'password', '123456', 'acme', 'jwt_secret']:
    token = jwt.encode(payload, secret, algorithm='HS256')
    print(f'{secret}: {token}')

🔍 Finding #2: Role and permissions in the payload

json
"role": "admin",
"permissions": ["read", "write", "delete", "admin:users"]

The authorization model is embedded in the token. This tells me:

  • There's a role-based system (not just authenticated/unauthenticated)
  • Permissions are granular — admin:users suggests scoped access
  • The API might trust these claims without server-side verification

My next test: modify the role and see if the API trusts it.

⚠️ The attack

If I can crack or obtain the signing key, I change "role": "user" to "role": "admin"and sign a new token. If the API only checks the JWT claims (no database lookup), I'm admin now.

🔍 Finding #3: Internal infrastructure in the issuer

json
"iss": "auth.prod.us-east-1.acme.internal"

From this single string I learn:

  • auth — dedicated auth service (not monolithic)
  • prod — this is production (there's likely a staging too)
  • us-east-1 — AWS, US East region
  • acme.internal — internal domain naming convention

I now know to look for *.acme.internal subdomains, check if staging environments are exposed, and target AWS-specific misconfigurations (S3 buckets, metadata endpoints, etc.).

🔍 Finding #4: Token lifetime = security posture

json
"iat": 1704067200,   // Jan 1, 2024 00:00:00 UTC
"exp": 1735689600    // Jan 1, 2025 00:00:00 UTC

// Lifetime: exactly 365 days
text
┌─ TOKEN LIFETIME → SECURITY INFERENCE ────────────┐
│                                                  │
│  5-15 min   → Refresh flow exists. Good.         │
│  1-4 hours  → Acceptable for low-risk APIs.      │
│  24 hours   → No refresh. Moderate risk.         │
│  7+ days    → Lazy implementation. High risk.    │
│  365 days   → "We'll fix it later." Critical.    │
│  No exp     → Token valid forever. Game over.    │
│                                                  │
└──────────────────────────────────────────────────┘

A 365-day token means: no refresh rotation, no revocation mechanism (or a broken one), and if this token leaks, the attacker has a full year of access. I've seen production APIs where logging out doesn't even invalidate the token — it just deletes it from localStorage.

🔍 Finding #5: Multi-tenancy and cross-tenant attacks

json
"tenant_id": "t_442"

The app is multi-tenant. My token is scoped to tenant t_442. But does the API actually enforce this? I test:

http
# My tenant
GET /api/tenants/t_442/users HTTP/1.1 → 200 ✓

# Someone else's tenant
GET /api/tenants/t_443/users HTTP/1.1 → ???

# If 200 → cross-tenant data access
# If 403 → properly scoped (rare in my experience)

About 40% of multi-tenant APIs I test have at least one endpoint where the tenant_id in the URL isn't validated against the tenant_id in the token.

What you should do about it

💡 Defensive checklist

  • • Keep payloads minimal — sub + exp + iat is enough
  • • Never put roles/permissions in the token if you can look them up server-side
  • • Use RS256/ES256 over HS256 for multi-service architectures
  • • Strip internal hostnames from iss — use an opaque identifier
  • • Token lifetime: 15 min access + rotating refresh tokens
  • • Validate aud claim in every service

The token is a postcard, not a sealed envelope. Treat it accordingly.

Related posts

Written by Eko

If you found this useful, follow @ekofyi on X for more notes like this — or get in touch if you have a problem to solve.