When we started building our fintech platform, we did what most teams do: we used JWTs. JSON Web Tokens are standard, fast, well-supported, and easy to implement. Every library has them. Every tutorial covers them.
But about eight months into production, we ran into a problem that most tutorials do not talk about.
The Problem with Standard JWTs
A standard JWT is signed, not encrypted. That means the payload — the actual claims inside the token — is just base64-encoded. Anyone who intercepts the token can read it. Not modify it without invalidating the signature, but read it.
For most applications, that is fine. If your JWT payload contains just a user ID and a role, leaking that in a network log or browser history is not catastrophic.
For us, it was a different story.
Our JWTs contained:
- The user's tenant ID
- Their KYC status
- Their investment tier (which directly corresponded to which properties they could access)
- A partner_id that mapped to our B2B distribution partners
We were white-labeling the platform to wealth management companies. Each partner had their own subdomain, their own branding, and their own set of investors. The partner_id in the JWT was being used to scope data at the API layer.
A security consultant we hired for a compliance review flagged this. The JWT payload was readable by anyone — the investor, the partner, any intermediary proxy, any logging system that captured Authorization headers. For a regulated fintech product handling investor data, this was a liability.
The consultant's recommendation: encrypt the token payload, not just sign it. Use JWE.
What JWE Actually Is
JWE (JSON Web Encryption) is the encryption counterpart to JWS (JSON Web Signature, which is what a standard JWT uses). Instead of just signing the payload with a secret key, JWE encrypts the entire payload so it is unreadable without the private key.
A JWE compact serialization looks like five base64url-encoded segments separated by dots:
header.encrypted_key.iv.ciphertext.tag
Where:
- header: algorithm metadata (e.g., RSA-OAEP for key wrapping, AES-256-GCM for content encryption)
- encrypted_key: the CEK (Content Encryption Key) encrypted with the recipient's public key
- iv: initialization vector
- ciphertext: the actual encrypted payload
- tag: authentication tag from AES-GCM
We chose RSA-OAEP (RSA with Optimal Asymmetric Encryption Padding) for the key wrapping algorithm and AES-256-GCM for content encryption. This is a strong, widely-supported combination.
The Implementation
We used the jose library in Node.js. It has excellent JWE support and is actively maintained.
Key Generation:
const { generateKeyPair } = require('jose');
const { publicKey, privateKey } = await generateKeyPair('RSA-OAEP-256', {
modulusLength: 2048,
extractable: true,
});
const publicKeyJWK = await exportJWK(publicKey);
const privateKeyJWK = await exportJWK(privateKey);We stored the public key in our app config (used for encryption during token issuance) and the private key in AWS Secrets Manager (used for decryption during token validation).
Token Issuance:
const { CompactEncrypt } = require('jose');
async function issueJWE(payload) {
const publicKey = await importJWK(PUBLIC_KEY_JWK);
const encoder = new TextEncoder();
const jwe = await new CompactEncrypt(encoder.encode(JSON.stringify(payload)))
.setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' })
.encrypt(publicKey);
return jwe;
}Token Validation:
const { compactDecrypt } = require('jose');
async function validateJWE(token) {
const privateKey = await importJWK(PRIVATE_KEY_JWK);
const { plaintext } = await compactDecrypt(token, privateKey);
const payload = JSON.parse(new TextDecoder().decode(plaintext));
return payload;
}This is the core implementation. But the migration from JWT to JWE broke things in ways we did not anticipate.
What Broke
Issue 1: Token size. A JWE token is significantly larger than a JWT. Our typical JWT was about 280 bytes. The JWE equivalent was around 850 bytes. This caused two problems.
First, cookies. We were storing the token in an HttpOnly cookie with a size limit. We had to restructure the cookie strategy — we moved to storing a session reference in the cookie and keeping the JWE in server-side session storage (Redis).
Second, mobile headers. Our Flutter app was sending the JWE as a Bearer token in the Authorization header. Some older API gateway configurations have header size limits. We had to increase the max header size in our Nginx config:
large_client_header_buffers 4 32k;
Issue 2: Performance. RSA-OAEP decryption is computationally expensive. Under load, we saw our auth middleware taking 12-18ms per request just for decryption. At high throughput, this added up.
Fix: we decrypted the JWE once per request and cached the payload in request context. We also moved to a hybrid approach: the JWE is validated once at login to extract the payload, then we issue a short-lived signed JWT (not JWE) for the session. The JWE becomes the refresh token; the JWT becomes the access token. This gave us the security benefits of encrypted tokens for sensitive data (the refresh token) with the performance of lightweight signed tokens for the high-frequency access token.
Issue 3: Key rotation. With symmetric JWT signing (HMAC-SHA256), rotating the secret key was straightforward: update the env variable and republish. With RSA key pairs, rotation is more involved.
We implemented a JWKS (JSON Web Key Set) endpoint that exposed our current public keys with a kid (Key ID) field. During decryption, we check the kid in the JWE header, look up the corresponding private key, and use that for decryption. This allows us to have multiple active keys during a rotation window, then retire old keys after all tokens issued with them have expired.
The Security Outcome
After the migration:
- Token payloads are fully encrypted. Logs, proxies, and clients cannot read the contents.
- Sensitive claims (partner_id, KYC status, investment tier) are not exposed at the transport layer.
- The compliance team signed off. The security consultant closed the finding.
- We passed the security audit from the institutional partner that required encrypted session tokens.
Was It Worth It
Yes. But I would not recommend going all-in on JWE for access tokens at high throughput. The hybrid model — JWE for refresh tokens, short-lived signed JWT for access tokens — gives you most of the security benefit with manageable performance overhead.
The things I wish someone had told me before we started:
1. Plan for token size from day one. If you are near cookie or header limits, JWE will push you over.
2. RSA decryption is slow. Cache aggressively or use the hybrid model.
3. Key rotation needs a JWKS endpoint, not just an env variable update.
4. Test your mobile clients. JWE tokens in auth headers can hit gateway limits you did not know existed.
JWTs are not broken. But for regulated, multi-tenant systems where the payload contains sensitive operational data — JWE is the right call.