Today’s Lesson
Security for Legal SaaS — Episode 18: JWT Anatomy and Pitfalls
The Token That Changed Web Authentication
JSON Web Tokens transformed how web applications handle authentication. Instead of server-side session storage, a JWT encodes identity claims into a self-contained, cryptographically signed token that the client carries. RFC 7519 defines the standard — and since its publication in 2015, JWTs have become the default authentication mechanism for SPAs (Single-Page Applications — web apps where the entire interface runs in the browser), mobile apps, and API services.
Key insight: JWTs shift trust from server state to cryptographic verification. This is powerful — and dangerous. Every vulnerability in JWT implementation is a vulnerability in your entire authentication layer. PortSwigger’s JWT security research documents over a dozen exploitable attack patterns.
For legal SaaS, where multi-tenant isolation and privilege boundaries are existential concerns, understanding exactly what JWTs guarantee — and what they cannot — is the difference between secure architecture and a breach waiting for a trigger.
JWT Structure — Three Parts, Base64-Encoded
A JWT consists of three Base64URL-encoded segments separated by dots: header.payload.signature.
Header
{
"alg": "RS256",
"typ": "JWT",
"kid": "2024-signing-key-01"
}
The header declares the signing algorithm and optionally a key ID for key rotation. RFC 7518 (JSON Web Algorithms) defines the allowed algorithms.
Payload (Claims)
{
"sub": "user_8f3a2b",
"iss": "https://auth.legalplatform.com",
"aud": "https://api.legalplatform.com",
"iat": 1716000000,
"exp": 1716003600,
"tenant_id": "firm_baker_mckenzie",
"role": "partner",
"scope": "documents:read documents:write matters:read"
}
| Claim | Purpose | Security Implication |
|---|---|---|
sub | Subject (user ID) | Must be opaque — never use email addresses |
iss | Issuer | Verify on every request — reject tokens from unexpected issuers |
aud | Audience | The service this token is intended for — reject mismatched audiences |
exp | Expiration | Enforce strictly — no grace periods beyond clock skew |
iat | Issued at | Enables detection of tokens issued before a security event |
tenant_id | Custom: tenant isolation | Enforce at API layer — never trust the token alone for tenant boundaries |
scope | Permissions | Principle of least privilege — request minimum needed |
Signature
The signature is computed over the header and payload: HMAC-SHA256(base64url(header) + "." + base64url(payload), secret) for symmetric algorithms, or an RSA/ECDSA signature for asymmetric ones.
The “none” Algorithm Attack
Critical vulnerability: Auth0’s 2015 disclosure revealed that multiple JWT libraries accepted tokens with "alg": "none" — the “unsecured JWS” defined in RFC 7519. An attacker strips the signature entirely, sets the algorithm to “none,” modifies any claims they want (escalate role, change tenant_id), and the server accepts it.
How it works:
- Attacker intercepts a valid JWT
- Decodes the header, changes
"alg": "RS256"to"alg": "none" - Modifies payload claims (elevate privileges, switch tenants)
- Removes the signature segment
- Sends:
eyJ...modified_header.eyJ...modified_payload.
Defence: Never allow the token to specify its own algorithm. Configure your verification library with a hardcoded expected algorithm. Reject any token whose header algorithm doesn’t match. Most modern libraries do this by default, but legacy code and misconfiguration remain common.
Algorithm Confusion (RS256 → HS256)
A subtler variant: if a server uses RS256 (asymmetric — signs with private key, verifies with public key), an attacker can:
- Obtain the public key (often exposed at
/.well-known/jwks.json— JWKS being the JSON Web Key Set, a standard endpoint where servers publish their public signing keys) - Set the header algorithm to HS256 (symmetric)
- Sign the modified token using the public key as the HMAC secret
- If the server blindly trusts the algorithm header, it verifies using the public key as an HMAC secret — and the signature passes
Tim McLean’s original research demonstrated this across multiple JWT libraries. The fix: always specify the expected algorithm in your verification call.
Token Lifetime Tradeoffs
| Parameter | Short-Lived (5–15 min) | Long-Lived (hours/days) |
|---|---|---|
| Exposure window | Minimal | Dangerous |
| User friction | Frequent re-auth (mitigated by refresh tokens) | Seamless |
| Revocation need | Low — tokens expire naturally | High — can’t wait for expiry |
| Storage risk | Lower impact if stolen | Catastrophic if stolen |
Access + Refresh Token Pattern
OAuth 2.0 (RFC 6749) established the pattern:
- Access token: Short-lived (5–15 minutes). Sent with every API request. Stateless verification.
- Refresh token: Longer-lived (days–weeks). Stored securely. Exchanged for new access tokens. Can be revoked server-side.
For legal SaaS: access token at 15 minutes (short enough that revocation is rarely needed), refresh token at 7 days (stored in httpOnly cookie, rotated on each use). OWASP recommends refresh token rotation — if a refresh token is used twice (indicating theft), invalidate all tokens for that user immediately.
What JWTs Cannot Do
Immediate Revocation
JWTs are verified without contacting the server. Once issued, they’re valid until expiry. You cannot “delete” a JWT. This creates a fundamental problem: if a user’s account is compromised, if they’re deprovisioned, if their permissions change — the existing JWT remains valid until it naturally expires.
| Strategy | Latency | Cost |
|---|---|---|
| Short expiry (5 min) | 5 min max exposure | Frequent refreshes |
| Token blocklist (Redis/memory) | Near-instant | Requires server state — partially defeats the purpose |
| Token versioning (per-user counter) | On next verification | Requires database lookup — partially defeats the purpose |
| Event-driven invalidation | Seconds (pub/sub) | Infrastructure complexity |
Server-Side State
The promise of JWTs is statelessness. But real applications need revocation, audit logging, concurrent session limits, and permission changes to take effect immediately. Thomas Ptacek’s critique: “If you need server-side state anyway (and you do), you might as well use sessions.”
Pragmatic guidance: Use JWTs for stateless service-to-service authentication where revocation is less critical. Use stateful sessions (server-side session ID in a cookie) for user-facing authentication where you need immediate revocation, concurrent session control, and activity tracking. Many production systems use both — JWT between microservices (small, independent services that each handle one function), sessions for the user-facing layer.
When to Use JWTs vs Stateful Sessions
| Criterion | JWT | Stateful Session |
|---|---|---|
| Immediate revocation needed | No — use sessions | Yes |
| Distributed microservices | Yes — no shared session store needed | Requires distributed cache |
| Concurrent session limits | Difficult | Native |
| Audit trail of active sessions | Difficult | Native |
| Scalability | Excellent — no server state | Requires session store scaling |
| Offline/mobile | Good — token is self-contained | Requires connectivity |
| Sensitive legal operations | Consider sessions | Preferred |
OWASP’s Session Management Cheat Sheet recommends server-side sessions for applications requiring “immediate invalidation capability” — which describes every legal SaaS platform handling privileged documents.
JWT Security Checklist for Legal SaaS
| Control | Implementation |
|---|---|
| Algorithm hardcoding | Verify with explicit algorithms=["RS256"] — never trust the header |
| Signature verification | Verify every token on every request — no exceptions |
| Claims validation | Check iss, aud, exp, iat on every verification |
| Key management | Rotate signing keys annually; support multiple kid values during rotation |
| Token storage (browser) | httpOnly, Secure, SameSite=Strict cookies — never localStorage |
| Refresh token rotation | New refresh token on each use; detect reuse as compromise |
| Scope minimisation | Issue tokens with minimum required permissions |
| Tenant isolation | Validate tenant_id claim against the resource being accessed — defence in depth |
| Clock skew | Maximum 30 seconds tolerance for exp verification |
| Token size | Keep payloads minimal — JWTs travel with every request |
The jwt.io debugger is invaluable for inspecting tokens during development — but note that pasting production tokens into third-party websites exposes their contents. Decode locally with base64 -d for production tokens.
Real-World JWT Vulnerabilities
In 2022, researchers discovered that Microsoft Azure AD tokens could be forged due to a validation bypass — the Storm-0558 incident where a stolen signing key allowed forging tokens for any Azure AD user. The Auth0 “algorithm confusion” disclosure affected libraries in Java, Node.js, PHP, and Python simultaneously.
For legal SaaS, a forged JWT with an elevated tenant_id or role claim means an attacker can access another firm’s privileged documents. The cryptographic signature is the only barrier — and it’s only as strong as your algorithm choice, key management, and verification implementation.
RFC 9068 (JWT Profile for OAuth 2.0 Access Tokens) standardises the claims structure for access tokens, providing interoperability guidelines that reduce implementation errors.
Conclusion
JWTs are powerful but unforgiving. The “none” algorithm attack, algorithm confusion, and the fundamental inability to revoke issued tokens make JWT security a matter of precise implementation, not conceptual understanding. Hardcode your algorithms. Validate every claim. Keep access tokens short-lived. Use refresh token rotation. And ask the honest question: does your application actually need stateless tokens, or would server-side sessions — simpler, revocable, auditable — serve your security requirements better?
For legal SaaS handling privileged communications across multi-tenant boundaries, the answer is often both: sessions for user-facing authentication, JWTs for internal service communication.
Next episode: Session Management — the server-side approach, done right: from ID generation through lifecycle to invalidation.