A few months ago, a close friend who heads platform engineering at a high-growth SaaS startup reached out to me over the weekend. Their company had just undergone a comprehensive third-party security audit and penetration test in preparation for enterprise SOC 2 Type II compliance.
They expected minor observations—a missing security header here, a loose CORS policy there.
Instead, the audit team handed them a Critical Severity 10.0 vulnerability report, coupled with an architectural debrief that solved a mystery their infrastructure team had been battling for six months: why their API microservices choked and pegged CPU at only a few hundred concurrent requests.
The root cause was a subtle, widespread cryptographic misunderstanding regarding how Public Key Cryptography interacts with JSON Web Tokens (JWT).
Here is the deep architectural breakdown of the Public Key Trap, the underlying mathematics of RSA exponentiation, why Nested JWTs are usually a production mistake, and the distributed state management patterns required to handle token revocation properly.
1. The Trap: Confusing Confidentiality with Authenticity
When the startup designed their API authentication layer, their engineering team wanted to go beyond simple symmetric secrets (like a shared HMAC secret key). They chose asymmetric RSA keys so their frontend and distributed microservices could interact with tokens cleanly.
They used JWE (JSON Web Encryption) with RSA-OAEP-256 and A256GCM.
Here was their initial mental model:
[ Client / Browser ]
│
▼ (Encrypt payload with the server's PUBLIC key)
Encrypted Token: eyJhbGciOiJSU0EtT0FFUC0yNTYi...
│
▼ (Sent in Authorization: Bearer header)
[ API Gateway ]
│
▼ (Decrypt payload using the server's PRIVATE key)
Decrypted Payload: { user_id: 4201, role: "admin" }The team reasoned:
“We encrypted the payload using our RSA public key. Only our private key on the backend can decrypt it. An attacker intercepting the token over the wire sees only encrypted ciphertext. It is completely secure.”
The Lethal Vulnerability
In asymmetric cryptography:
The Public Key is public by design. It is served openly via JWKS endpoints /.well-known/jwks.json), bundled into mobile applications, or embedded in web clients.
Anyone in the world can encrypt anything with a public key.
Because the server only decrypted the payload using its private key and never validated a digital signature, the API had zero authenticity verification.
An attacker did not need to crack the private key. They simply took the public key and generated their own token:
```typescript
// THE UNIVERSAL FORGERY VULNERABILITY:
// Anyone holding the public key can craft an arbitrary authenticated session!
const forgedPayload = {
user_id: 1, // Target SuperAdmin or any arbitrary user
role: "super_admin",
tenant_id: "enterprise-target",
exp: Math.floor(Date.now() / 1000) + 3600
};
// Attacker encrypts it with the server's publicly available key
const attackerToken = await new EncryptJWT(forgedPayload)
.setProtectedHeader({ alg: "RSA-OAEP-256", enc: "A256GCM" })
.encrypt(serverPublicKey);
// Attacker hits: GET /api/v1/admin/billing
// The backend decrypts the ciphertext with its private key.
// Decryption succeeds! The server happily treats the attacker as user_id: 1.
```The Fundamental Rule
Encryption provides Confidentiality (preventing eavesdroppers from reading data).
Digital Signatures provide Authenticity & Non-Repudiation (proving who created the data and that it was not modified).
If an authentication token is encrypted with a public key but not digitally signed by a private key, it is completely unauthenticated and trivially forgeable by anyone with access to the public key.
2. The Computational Trap: The Mathematical Asymmetry of RSA
Once the audit team demonstrated how trivial it was to forge admin sessions, we began inspecting the startup’s production latency and CPU metrics.
For months, their engineers had assumed their Node.js event-loop delays and threadpool saturation were due to heavy JSON parsing or database connection pooling. The real culprit was the mathematical asymmetry of RSA.
The Mathematics: Why Modular Exponentiation Costs Differ
RSA Cryptography: Public vs. Private Key Operations
In standard RSA cryptography:
A key pair consists of a modulus (n), a public exponent (e), and a private exponent (d).
Public Key Operation:
[
c \equiv m^e \pmod n
]Private Key Operation:
[
m \equiv c^d \pmod n
]To make public key operations as fast as possible, standard cryptographic libraries set the public exponent (e) to a small integer with very few set bits. Almost universally, this value is 65537 ((2^{16} + 1)).
Computing (m^{65537} \pmod n) requires only 16 modular squarings and 1 modular multiplication using the standard square-and-multiply method.
In contrast, the private exponent (d) is a large, pseudo-random integer, typically of a similar bit length to the modulus (n) (e.g., 2048 or 4096 bits).
Computing (c^d \pmod n) requires significantly more modular operations, even when optimized using the Chinese Remainder Theorem (CRT).
The In-Process Benchmark Reality
When measured on identical hardware, the true algorithmic cost of RSA operations demonstrates a staggering performance gap:
```
┌────────────────────────────────────────────────────────────────────────────┐
│ ISOLATED RSA ALGORITHM PERFORMANCE │
├────────────────────────────────┬─────────────────┬────────────────────────┤
│ Cryptographic Operation │ p50 CPU Time │ Single-Core Throughput │
├────────────────────────────────┼─────────────────┼────────────────────────┤
│ RSA Public Key Verify (RS256) │ ~40.0 µs │ ~23,300 ops/sec │
│ RSA Public Key Encrypt (OAEP) │ ~65.0 µs │ ~13,800 ops/sec │
│ RSA Private Key Sign (RS256) │ ~570.0 µs │ ~1,670 ops/sec │
│ RSA Private Key Decrypt (OAEP) │ ~587.0 µs │ ~1,650 ops/sec │
└────────────────────────────────┴─────────────────┴────────────────────────┘
```Private key operations sign and decrypt) are ~14x more computationally expensive than public key operations verify and encrypt).
The Inverted Cost Curve
Look at where the startup was executing these operations in their request lifecycle:
```
[ THE FLAWED JWE ARCHITECTURE ]
Login / Token Issue: Public Key Encrypt (FAST: 65 µs) → Executed rarely (1% of traffic)
Every Single API Call: Private Key Decrypt (HEAVY: 587 µs) → Executed on 100% of requests!
```They had inverted the cost curve! Every single incoming API request—fetching user details, loading a page, checking a health status—forced their backend to perform an expensive 2048-bit RSA private-key decryption.
Because Node.js offloads crypto operations to the libuv thread pool (default size: 4), concurrent incoming traffic quickly saturated all 4 worker threads. Incoming requests queued behind crypto operations, causing event-loop lag to spike from 2ms to over 800ms and capping service throughput at ~30 requests/sec.
The Correct JWS Architecture
When you switch to JWS (JSON Web Signature) with RS256:
```
[ THE CORRECT JWS ARCHITECTURE ]
Login / Token Issue: Private Key Sign (HEAVY: 570 µs) → Executed rarely (1% of traffic)
Every Single API Call: Public Key Verify (FAST: 40 µs) → Executed on 100% of requests!
```
```
Per-Request Crypto Time: 587 µs (old JWE) ──► 40 µs (new JWS) (≈ 14x CPU Reduction)
Single-Core Verification: 1,650 req/s ──► 23,300 req/s (≈ 14x Throughput Ceiling)
```By keeping the private key strictly on the token-issuance side and using the public key on the verification side, you ensure that 99% of your traffic executes the fastest possible cryptographic operation.
3. The Secondary Trap: "Why Not Do Both? (Nested JWT: JWS inside JWE)"
When this was explained, the team asked a common question:
“Why not use Nested JWTs (RFC 7519 §11)? We can sign the payload with our Private Key first (guaranteeing authenticity), and then wrap that JWS inside a JWE encrypted with our Public Key (guaranteeing confidentiality)!”
While RFC 7519 defines this capability, in 99% of real-world production architectures, Nested JWT is an operational anti-pattern.
```
NESTED JWT RUNTIME COST
┌───────────────────────────────────────┐
│ Incoming Request │
│ Authorization: Bearer <Nested-JWT> │
└───────────────────┬───────────────────┘
│
▼
┌───────────────────────────────────────┐
│ Operation 1: RSA-OAEP Decryption │ ◄── CPU Heavy (~600 µs)
│ (Must use RSA Private Key) │
└───────────────────┬───────────────────┘
│ Yields inner JWS
▼
┌───────────────────────────────────────┐
│ Operation 2: RS256 Verification │ ◄── Crypto Verify (~40 µs)
│ (Uses RSA Public Key) │
└───────────────────┬───────────────────┘
│ Both operations required!
▼
┌───────────────────────────────────────┐
│ Controller Logic │
└───────────────────────────────────────┘
```Here are the 4 real-world reasons why Nested JWTs degrade production infrastructure:
1. Compounding CPU Penalties:
Your API servers must execute two cryptographic operations on every single API request: an RSA private-key decryption followed by an RSA signature verification. At 2,000 req/sec, this consumes significant CPU capacity just unpacking headers.
2. Network Packet Bloat & Cellular MTU Limits:
A standard signed compact JWS is roughly 350–500 bytes, whereas a Nested JWT can increase the size to approximately 1.8–3.2 KB.
Because mobile clients send this header with every HTTP request, this introduces significant bandwidth overhead.
The increased header size can also contribute to additional TCP/IP packet segmentation across standard 1500-byte MTU networks, potentially increasing network overhead and latency, particularly on cellular connections.
3. Breaks Edge Gateways & CDNs:
Modern infrastructure (Cloudflare Workers, Fastly, AWS API Gateway, Envoy, Kong) can natively verify RS256 signatures in sub-millisecond time at the edge using only a public JWKS endpoint. If you use Nested JWE, edge proxies cannot inspect, route, or authorize requests without distributing your master private key to the edge—introducing a severe security risk.
4. Zero Practical Gain Over TLS:
Examine what is actually in your token claims:
```json
{ "sub": "usr_9912", "tenant": "corp_alpha", "role": "editor", "exp": 1793686400 }
```There are no passwords or private API keys. Transport security is already handled by TLS 1.3 / HTTPS, which encrypts all HTTP headers and payloads on the wire. Encrypting non-sensitive metadata inside a secondary JWE wrapper yields negligible practical security gain while imposing severe performance penalties.
4. The Revocation Trap: Raw JWTs in Redis vs. RFC 7519 JTI
Once the cryptographic model is corrected, the next architectural failure mode usually occurs in session invalidation and logout.
The Anti-Pattern: Storing Raw Tokens in Cache
In their legacy codebase, logging out a user or revoking a token looked like this:
```typescript
// ANTI-PATTERN: Storing raw token strings
const token = req.headers.authorization?.split(" ")[1];
// Storing full 500-byte token string with arbitrary 30-day TTL:
await redis.setjwt:block:${token}, "1", { EX: 30 24 60 * 60 });
```This pattern introduces three major architectural issues:
1. Cache Memory Bloat: Storing approx 500 bytes per key instead of a 36-byte UUID increases memory consumption by 10 times. Across millions of logged-out sessions, Redis memory fills up with string keys.
2. Arbitrary TTL Accumulation: If access tokens expire in 1 hour, retaining their revocation keys in Redis for 30 days accumulates millions of dead records for tokens that are already cryptographically invalid.
3. Multi-Tenant State Pollution: For guest sessions or multi-device setups, storing full token strings makes it cumbersome to query or revoke active sessions cleanly per tenant or user.
The Clean Solution: JTI (JWT ID) Revocation
A. Standardize on the RFC 7519 jti Claim
Every minted token (whether for an authenticated user or a temporary guest session) must carry a unique UUIDv4 jti claim:
```typescript
import crypto from "crypto";
const jti = crypto.randomUUID(); // Immutable 36-byte token identifier
const now = Math.floor(Date.now() / 1000);
const exp = now + 60 * 60; // 1-hour expiration
const token = await new SignJWT(payload)
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
.setJti(jti)
.setIssuedAt(now)
.setExpirationTime(exp)
.sign(privateKey);
```When revoking a token, you store only the compact JTI key:
text{Redis Revocation Key: } {jwt:block:jti:{uuid}}
We can extract jti and exp from the base64-encoded payload segment without incurring signature verification overhead:
```typescript
export function extractJtiAndExp(token: string): { jti?: string; exp?: number } {
try {
const parts = token.split(".");
if (parts.length === 3) {
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
return {
jti: typeof payload.jti === "string" ? payload.jti : undefined,
exp: typeof payload.exp === "number" ? payload.exp : undefined,
};
}
} catch {
// Fail-safe
}
return {};
}
```B. The Clock-Drift Race Condition (+120s Grace Buffer)
If you set the Redis revocation TTL to exactlyexp - now, you introduce a race condition under distributed NTP clock skew.
Consider this sequence:
1. A user logs out. The token’s expiration timestamp is $T_{\text{exp}} = 12:00:00$.
2. Redis evicts the revocation key at exactly $12:00:00$.
3. Application Server B has a minor NTP clock drift of $-2\text{ seconds}$ ($11:59:58$).
4. An attacker replays the logged-out token against Server B at $12:00:01$.
5. The Window: Server B queries Redis: the key was evicted. Server B evaluates cryptographic expiration: $11:59:58 < 12:00:00$ (evaluated as valid!). The revoked token is accepted.
To eliminate this vulnerability, apply a +120-second safety grace buffer:
{Redis JTI TTL} = \max(0, \{expEpochSeconds} - \{nowSeconds}) + 120\{ seconds}
```typescript
const nowSeconds = Math.floor(Date.now() / 1000);
let ttl = 30 24 60 * 60; // Safe fallback
if (expEpochSeconds && expEpochSeconds > 0) {
// Grace buffer: ensures the revocation key strictly outlives cryptographic validity
ttl = Math.max(0, expEpochSeconds - nowSeconds) + 120;
}
await redis.setjwt:block:jti:${jti}, "1", { EX: ttl });
```Why this is mathematically sound:
While $T < text{exp}$: The token is rejected by the Redis JTI blocklist.
While text{exp} e T e {exp} + 120{s}: The token is rejected by BOTH the Redis JTI blocklist AND cryptographic expiration checks jwtVerify throws ERR_JWT_EXPIRED).
When T > {exp} + 120{s}: Redis auto-evicts the JTI key. The token remains unusable forever because cryptographic verification rejects it permanently.
Result: Zero memory leaks in Redis and zero vulnerability to NTP clock drift.
C. Verification Ordering: Do Not Let Attackers DoS Your Redis
A critical flaw in many token middleware implementations is checking the Redis revocation blocklist before verifying the cryptographic signature:
```
[ WRONG ORDERING: VULNERABLE TO REDIS DOS ]
Request ──► Query Redis (EXISTS jwt:block:jti:xyz) ──► Verify RS256 Signature
```If you do this, an unauthenticated attacker can flood your endpoints with 30,000 requests/sec containing random, synthetic jti strings. Every request forces a Redis roundtrip, exhausting connection pools and causing a denial-of-service (DoS) for legitimate traffic.
The Correct Sequence:
```
[ CORRECT ORDERING: DEFENSE-IN-DEPTH ]
Request
│
▼
1. Format Check: Validate 3-part structure (O(1) memory)
│
▼
2. Signature Verification: RS256 verify with Public Key
(Forged or tampered tokens are rejected here with ZERO Redis overhead!)
│
▼
3. Revocation Check: Query Redis only for cryptographically authentic JTIs
│
▼
4. Controller Logic
```D. Zero-Downtime Rollout (Dual-Read Fallback)
During a live rolling deployment across a container cluster, tokens revoked immediately before the upgrade are stored under legacy keys jwt:block:{raw_token}).
If upgraded instances only query jwt:block:jti:{jti}, in-flight blocked tokens would suddenly appear valid on upgraded pods.
To achieve zero downtime, deploy with a dual-read resolution:
```typescript
export async function isTokenBlocked(token: string, jti?: string): Promise<boolean> {
const client = getRedisClient();
if (!client) return false;
try {
// 1. Primary check: Fast 36-byte JTI key
if (jti) {
const jtiExists = await client.existsjwt:block:jti:${jti});
if (jtiExists === 1) return true;
}
// 2. Fallback check: Legacy raw token key (preserves in-flight rollout blocks)
if (token) {
const tokenExists = await client.existsjwt:block:${token});
if (tokenExists === 1) return true;
// 3. Fallback parse: extract JTI from token if caller didn't pass it
if (!jti) {
const { jti: extractedJti } = extractJtiAndExp(token);
if (extractedJti) {
const exists = await client.existsjwt:block:jti:${extractedJti});
if (exists === 1) return true;
}
}
}
return false;
} catch (err) {
console.error("[RedisAuth] Error checking revocation state:", err);
return false; // Fail-open to preserve API availability
}
}
```5. Architectural Comparison Matrix
| Architectural Dimension | Flawed Approach (Public Key Encryption) | Standard Production Approach (JWS RS256 + JTI) |
| :--- | :--- | :--- |
| Primary Mechanism | JWE Encrypt with Public Key | JWS Sign with Private Key, Verify with Public Key |
| Authenticity Guarantee | None (Anyone with the public key can forge tokens) | Mathematically Guaranteed (Only private key holder can mint) |
| Per-Request Crypto Cost | approx 600 ms (Heavy RSA-OAEP private-key decrypt) | approx 40 ms (Fast RSA public-key verification) |
| Single-Core Crypto Ceiling | approx 1,650 req/sec (Threadpool saturation) | approx 23,300\req/sec |
| Token Size on Wire | approx 1.5 - 3.0KB (Header bloat) | approx 350 - 500 bytes (Compact) |
| Edge Gateway Compatibility | Incompatible (Edge proxies cannot decrypt without private key) | Native (Cloudflare / Kong / Envoy verify JWKS at edge) |
| Redis Memory per 100k Sessions| approx 50 (Full token strings) | approx 3.6 MB (36-byte JTI UUIDs) |
| Clock-Drift Resilience | Vulnerable to NTP desynchronization | Immune via +120 grace TTL buffer |
| DoS Resilience | Vulnerable if Redis check precedes decryption | Protected (Signature verified before Redis check) |
6. The Production Readiness Checklist
Save this checklist for your next security audit, architecture design review, or refactoring cycle:
```
[ ] 1. Authenticity Over Encryption:
Access tokens must use digital signatures (RS256, ES256, or EdDSA),
NEVER unauthenticated public-key encryption.
[ ] 2. Align the Cost Curve:
Private key operations (heavy) must run ONLY during token issuance (login/refresh).
Public key operations (light) must run on every request (verification).
[ ] 3. Avoid Unnecessary Nested JWEs:
Rely on TLS 1.3 for on-the-wire confidentiality. Keep access tokens as
compact JWS signatures unless strict regulatory payload encryption is required.
[ ] 4. JTI-Based Session Revocation:
Revocation blocklists must store immutable 36-byte UUIDs jwt:block:jti:{uuid}),
never full serialized token strings.
[ ] 5. Dynamic TTL with Clock-Drift Buffer:
Revocation keys must expire at:
TTL = max(0, exp - now) + 120 seconds
This prevents Redis memory bloat while closing the distributed NTP drift window.
[ ] 6. Signature-First Gate:
Always verify the cryptographic signature BEFORE reading from your cache.
Unauthenticated attackers must never trigger database or cache lookups.
[ ] 7. In-Process Verification Benchmarks:
Measure real ops/sec on your target production instances using compiled binaries,
validating that token verification occupies < 5% of your total request latency.
```Summary
Security architectures that feel "more complex" and "more encrypted" are often less secure and substantially slower.
1. Asymmetric cryptography has strict directionality: Use your private key to sign, public key to verify.
2. Encryption without digital signatures is an open door: If the encryption key is public, anyone can forge identity.
3. Keep your cache lean: Store the jti claim with an expiration grace buffer, not the raw JWT.
Getting these fundamentals right ensures your microservices remain secure, lightweight, and capable of scaling to tens of thousands of requests per second without breaking a sweat.