Encrypting API Responses End-to-End: What Most Backends Get Wrong (And How to Fix It)
Subtitle: A production walkthrough of AES-256-GCM response encryption between a Node.js API and a React client covering per-request salts…
Encrypting API Responses End-to-End: What Most Backends Get Wrong (And How to Fix It)
Subtitle: A production walkthrough of AES-256-GCM response encryption between a Node.js API and a React client covering per-request salts, key storage, Web Crypto API parity, and the versioned envelope format that makes backward compatible migrations possible.

Article cover for ‘Encrypting API Responses End-to-End: What Most Backends Get Wrong (And How to Fix It)`
If your API serves sensitive data and you’re relying on HTTPS alone to protect it, you have a false sense of security. Here’s what real transport-layer encryption looks like and the exact mistakes that make it fail in production.
There is a question that almost never gets asked during backend reviews: what happens to your data after TLS terminates?
HTTPS encrypts the wire between your client and your server. That is genuinely important. But TLS terminates at your load balancer, your reverse proxy, or your CDN edge node not at your application. From that termination point to your Node.js process, your Express middleware, your MongoDB query, and back out again, the data travels in plaintext. If your infrastructure is compromised at any internal hop, a rogue container, a misconfigured proxy, a cloud provider breach your “encrypted” API is serving plaintext JSON.
This is not a theoretical concern. The 2019 Capital One breach exposed 100 million customer records without the attacker ever intercepting network traffic. The method was a misconfigured Web Application Firewall that allowed a Server-Side Request Forgery attack, the attacker used it to query AWS’s internal metadata service, retrieved temporary IAM credentials, and used those credentials to pull data directly from S3 buckets. Every S3 request was authenticated and encrypted in transit. TLS was working. The breach happened entirely above the transport layer.
This article explains how to build genuine end-to-end encryption between your backend API and your frontend clients where the data is encrypted before it leaves your application process and can only be decrypted by a client holding the right key. I will use a real production system I built as the case study: an API that serves product data to organizations. The encryption had to work across Node.js on the server and the Web Crypto API in the browser.
By the end of this article you will understand why naive AES-GCM implementations break down in API-as-a-service contexts, and why the most dangerous part of an encryption system is often the key storage, not the cipher.
Why HTTPS Is Not Enough (And Never Was)
SSL/TLS is a transport security protocol. Its threat model is the network: eavesdropping on packets between client and server, man-in-the-middle attacks on the wire, DNS spoofing. It is extremely good at all of those things.
Its threat model explicitly does not include:
- What happens to data once it reaches your server
- What your infrastructure provider can see at the termination point
- What a compromised internal service can read when it calls your API
- What is stored in your database if the database is breached
- What a rogue employee with cloud console access can see in your logs
The moment you run your API behind a load balancer which is every production deployment, TLS terminates before your application ever sees the request. Your app receives plaintext HTTP internally. Every log line, every middleware inspection, every proxy debug dump, every infrastructure metric that captures request bodies: all plaintext.
For an API that serves public product, this is fine. For an API that serves proprietary personally information to paying subscribers, it is not.
The fix is application-layer encryption: encrypt the response payload inside your application process, before it is handed to the HTTP layer, using a key that only the legitimate client possesses. The network, the infrastructure, the load balancer, the CDN, the database none of them can read it. Only the client with the matching key can.
The Real Use Case: API-as-a-Service with Per-Client Keys
The system I built serves a REST API to 2 categories of users: free individual accounts, paying accounts with API keys. Every product query response is encrypted at the application layer before being sent over the wire.
The threat model was specific:
- Infrastructure breach: if a cloud provider, container orchestrator, or internal service were compromised, response payloads in transit internally should be unreadable.
- Log exposure: request/response logging should never capture sensitive payload content.
- Multi-tenant key isolation: a key belonging to one user should not be usable to decrypt another user’s responses, even if both made the same query.
- Zero server-side plaintext storage: the decryption key should never be stored in plaintext anywhere on the server.
Each of these constraints determined specific technical decisions I made. Let me walk through them in order.
Building the Encryption Envelope
The Cipher Choice
AES-256-GCM is the right cipher for this use case. It provides:
- Confidentiality: the data is unreadable without the key.
- Integrity: GCM(Galois/Counter Mode) mode produces an authentication tag that detects any tampering with the ciphertext. If a byte is flipped in transit, decryption fails loudly rather than silently returning corrupted data.
- Performance: AES hardware acceleration (AES-NI) is available on virtually every modern CPU and is used automatically by both Node.js’s
cryptomodule and the browser's Web Crypto API.
The naive implementation looks like this:
// ⚠️ DO NOT USE — this has a critical flaw explained below
const iv = crypto.randomBytes(16);
const salt = crypto.createHash('sha256').update('my-app-salt').digest();
const key = crypto.pbkdf2Sync(encryptionKey, salt, 100000, 32, 'sha512');
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Envelope: IV + authTag + ciphertext, base64-encoded
const envelope = Buffer.from(
iv.toString('hex') + authTag.toString('hex') + encrypted
).toString('base64');
This works. A client can decrypt it. The authentication tag detects tampering. The IV is random per request, so the same plaintext never produces the same ciphertext. But there is a flaw that only becomes visible in a multi-tenant API context: the salt is hardcoded.
Why the Fixed Salt Breaks Multi-Tenant Security
PBKDF2’s (Password-Based Key Derivation Function 1 and 2) purpose is to turn a password (here: the user’s encryptionKey) into a cryptographic key suitable for AES. The salt's job is to ensure that two users with the same password produce different derived AES keys. Without it, a precomputed table of password → AES key mappings works across all users simultaneously.
In this case, every user’s encryptionKey is crypto.randomBytes(32).toString('hex') — 256 bits of entropy. Nobody is brute-forcing 256 bits regardless of the salt. So why does it matter?
It matters because the salt being in your source code means:
- Anyone who has read your source code (ex-employees, a GitHub leak, a code audit that went wrong) knows the exact derivation parameters for every user’s AES key.
- The PBKDF2 function becomes fully deterministic and identical across all tenants. Given user A’s
encryptionKey, anyone can derive user A's AES key offline, with no server access, just the source code and the key string. - If a user’s
encryptionKeyis ever exposed from their own device, from a support conversation, from their own logs the attacker can immediately derive the AES key used for every request, past and future, because the derivation is always the same.
The fix is to generate a random salt per encryption and include it in the envelope. The client reads it out and uses it for key derivation:
// ✅ v2 envelope: version(1) + salt(32) + IV(16) + authTag(16) + ciphertext
static encrypt(data: any, encryptionKey: string): string {
const salt = crypto.randomBytes(32); // unique per encryption call
const iv = crypto.randomBytes(16);
const key = crypto.pbkdf2Sync(encryptionKey, salt, 100_000, 32, 'sha512');
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) as crypto.CipherGCM;
let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
const combined =
Buffer.from([0x02]).toString('hex') + // version flag
salt.toString('hex') + // 32 bytes: random salt
iv.toString('hex') + // 16 bytes: random IV
authTag.toString('hex') + // 16 bytes: GCM auth tag
encrypted; // variable: ciphertext
return Buffer.from(combined, 'hex').toString('base64');
}
One important detail about key derivation in the browser: Node.js’s crypto.pbkdf2Sync treats a string password argument as UTF-8. The Web Crypto API's importKey with 'raw' encoding also expects a Uint8Array. These must match. The correct approach is:
// Node.js server — string argument is UTF-8 encoded automatically
crypto.pbkdf2Sync(encryptionKey, salt, 100_000, 32, 'sha512');
// Browser — must explicitly use TextEncoder to get the same UTF-8 bytes
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(encryptionKey), // UTF-8 — matches Node's behavior
'PBKDF2',
false,
['deriveKey']
);
Using hexToUint8Array instead of TextEncoder on the browser side is a common mistake that produces a different byte sequence and a decryption failure with no obvious error message.
The Key Storage Problem Nobody Talks About
The encryption above protects data in transit. But what about the encryption keys themselves?
In the naive implementation, the encryptionKey for each API key is stored as a plaintext string in MongoDB:
// ApiKey document in MongoDB
{
apiKey: "api_sk_a3f9...",
hashedKey: "sha256hash...",
encryptionKey: "a3f9c2b1..." // ← plaintext in your database
encryptionKey: { index: true } // ← and indexed, making it enumerable
}
This means a MongoDB breach exposes every user’s encryption key immediately. An attacker who dumps your database can decrypt every historical API response if they also capture your traffic logs. You have encrypted the transport but left the keys in a filing cabinet next to the road.
The fix is to encrypt the encryptionKey at rest using a server-side master key stored in an environment variable, never in the database:
// Before writing to MongoDB
static encryptKeyForStorage(rawEncryptionKey: string): string {
// Uses the server's ENCRYPTION_KEY env var, not the user's key
return this.encrypt(rawEncryptionKey, process.env.ENCRYPTION_KEY);
}
// After reading from MongoDB, before use
static decryptKeyFromStorage(storedValue: string): string {
return this.decrypt(storedValue, process.env.ENCRYPTION_KEY);
}
The One-Time Show
When a key is created, the raw encryptionKey is returned to the user once. It is never stored in plaintext anywhere on the server after that moment. This is the same model GitHub uses for Personal Access Tokens and Stripe uses for secret keys: show once, never retrieve.
What This Protects Against (And What It Does Not)
Being honest about the threat model is as important as implementing it correctly.
What application-layer encryption protects against:
- Traffic interception after TLS termination (internal proxies, load balancers, service meshes)
- Infrastructure-level breaches where an attacker can read internal HTTP traffic
- Log exposure of sensitive payload content
- Database breaches exposing encryption keys (with at-rest encryption)
- Unauthorized access by infrastructure providers or cloud operators
What it does not protect against:
- A fully compromised client device — if the attacker has the
encryptionKeyfrom the user's storage, they can decrypt everything. - Server-side memory inspection — the plaintext data exists in your Node.js process memory for the duration of request handling.
- A compromised
ENCRYPTION_KEYenvironment variable — if this is leaked, all storedencryptionKeyvalues can be decrypted and every response can then be decrypted. - Social engineering that obtains a user’s
encryptionKeydirectly.
The encryption described here raises the cost of a breach significantly. It does not make breaches impossible, nothing does. The goal is to ensure that a single point of infrastructure compromise does not immediately expose all user data. Each layer of the system must be independently compromised.
The Practical Checklist
If you are implementing application-layer encryption for an API, here is what actually matters:
Cipher: AES-256-GCM. It gives you confidentiality and integrity in one operation. Do not use AES-CBC (no integrity, older chained methods), do not use ECB mode (deterministic, leaks patterns), do not roll your own.
IV: Random per encryption call, never reuse. Include it in the envelope so the client can decrypt. A reused IV with the same key in GCM mode is catastrophic, it reveals the keystream and makes the authentication tag forgeable.
Salt: Random per encryption call, included in the envelope. Not hardcoded. Not derived from a constant string. This is the most commonly skipped step in tutorials.
Key derivation: PBKDF2 with SHA-512, 100,000 iterations minimum. The user’s raw key (high entropy) goes in, a fixed-size AES key comes out. The iteration count exists to slow down brute force 100,000 iterations means a million guesses per second hardware runs at roughly 10 guesses per second against your KDF. For 256-bit entropy keys this barely matters; for any human chosen passphrase it matters enormously.
Key storage: Never store encryption keys in plaintext in your database. Encrypt them at rest using a master key from your environment. The master key itself should live in a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager), not in a .env file committed to your repository.
Key lifecycle: Show the raw key once at creation. Provide a key rotation mechanism. Revoke keys immediately when a team member leaves or a compromise is suspected. Store only the hash of the API key string itself never the raw key for lookup purposes.
Versioning: Include a version byte in your envelope. You will change the format at some point. Clients update asynchronously. Without versioning, a format change requires a simultaneous deploy of server and all clients, which is not realistic.
Client/server parity: The browser’s Web Crypto API and Node.js’s crypto module must agree on every parameter: key encoding (UTF-8 string vs. raw bytes), PBKDF2 hash algorithm, iteration count, derived key length, GCM tag length, and tag position in the ciphertext buffer. A mismatch on any one of these produces a decryption failure with a generic "authentication failed" error that is extremely difficult to debug without having both sides visible simultaneously.
Why This Matters for Commercial Products
If you are building a paid API selling data, charging for queries, billing by subscription your encryption design is not just a security concern. It is a commercial one.
A user paying for access to your proprietary database is buying data that cost you time, sourcing, and validation to produce. If that data travels in plaintext through your infrastructure, the value of their subscription is undermined by anyone with access to your internal network. Your differentiated data is only as valuable as it is protected.
The model described here per-client encryption keys tied to API keys, at rest encryption of those keys, transparent decryption in the client SDK is the same model that financial data APIs, medical record APIs, and legal document APIs use to protect proprietary content. The implementation complexity is real but bounded: it lives in three places (encryption middleware, auth middleware, client interceptor) and adds approximately two round trips to PBKDF2 per request (one decrypt of the stored key, one encrypt of the response). On modern hardware with AES-NI, the AES-GCM step is submillisecond even for large payloads.
The encryption system described in this article was built into a API serving intelligence data to users. The v2 envelope format, per-client key isolation, and at-rest key encryption are all live in production.
메타데이터
- post_id
- dcd811b43dcb
- slug
- encrypting-api-responses-end-to-end-what-most-backends-get-wrong-and-how-to-fix-it-dcd811b43dcb
- url
- https://medium.com/@0l4m1de/encrypting-api-responses-end-to-end-what-most-backends-get-wrong-and-how-to-fix-it-dcd811b43dcb
- canonical_url
- https://medium.com/@0l4m1de/encrypting-api-responses-end-to-end-what-most-backends-get-wrong-and-how-to-fix-it-dcd811b43dcb
- author_url
- https://medium.com/@0l4m1de
- status
- ok
- fetched_at
- 2026-07-09 15:12:33