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
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzE3MzgiLCJlbWFpbCI6ImFkbWluQGFjbWUuaW8iLCJyb2xlIjoiYWRtaW4i
LCJ0ZW5hbnRfaWQiOiJ0XzQ0MiIsInBlcm1pc3Npb25zIjpbInJlYWQiLCJ3cml0ZSIsImRlbGV0
ZSIsImFkbWluOnVzZXJzIl0sImlzcyI6ImF1dGgucHJvZC51cy1lYXN0LTEuYWNtZS5pbnRlcm5h
bCIsImlhdCI6MTcwNDA2NzIwMCwiZXhwIjoxNzM1Njg5NjAwfQ.
<signature>Decoded payload:
{
"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
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.
| Algorithm | What it tells me | Attack surface |
|---|---|---|
HS256 | Shared secret across services | Brute-force weak secrets, key reuse |
RS256 | Public/private key pair | Key confusion attacks, JWKS endpoint |
none | No signature validation | Forge any token freely |
ES256 | ECDSA — mature setup | Limited (good sign) |
First thing I try with HS256: common weak secrets. You'd be surprised how often secret, password, or the company name works:
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
"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:userssuggests 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
"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 regionacme.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
"iat": 1704067200, // Jan 1, 2024 00:00:00 UTC
"exp": 1735689600 // Jan 1, 2025 00:00:00 UTC
// Lifetime: exactly 365 days┌─ 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
"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:
# 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+iatis 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
audclaim in every service
The token is a postcard, not a sealed envelope. Treat it accordingly.
Related posts
- API Reverse EngineeringMay 18, 20268 min read
5 things I check before trusting an API
Before I integrate with any API — official or reverse-engineered — I run through this checklist to avoid surprises later.
- API Reverse EngineeringApr 22, 202612 min read
How I reverse-engineered an HR attendance API in 3 days
A practical walkthrough of the methodology I use when there's no documentation: capturing traffic, mapping endpoints, and validating assumptions before writing a single line of automation.
- API Reverse EngineeringApr 8, 20268 min read
Reading the network tab without losing your mind
DevTools shows you everything, which is the problem. Here's how I filter signal from noise when reverse-engineering a web application's API.