End-to-end encryption for SaaS APIs: an X25519 + AES-GCM handshake that survives audit
TLS terminates at the edge — your logging proxy sees plaintext. A handshake protocol that keeps password and payment bodies encrypted all the way to the app boundary.
It is 11:32 PM. The on-call alert fired forty minutes ago. A payment processor integration was logging unexpected failures on password-reset flows, and the security engineer who caught it is not on the team that built the integration — she's on the platform security team and she was doing a routine review of the edge proxy's traffic mirror. She opens Burp Suite, replays three recent HTTP exchanges through the proxy's decode plugin, and stares at the screen.
The password reset request body — {"email":"[email protected]","token":"abc123","new_password":"Hunter2!"} — is sitting there in cleartext. Not in the raw TCP stream; TLS is doing its job at the network layer. The plaintext is in the WAF's request log, then in the load balancer's access log, then in the application's structured log that ships to the centralized log aggregator, and then in the error-tracking tool that captured a stack trace two days ago when one of the reset tokens expired mid-flight. Five separate systems saw that password.
This is not a TLS failure. TLS terminated exactly where it was supposed to: at the edge proxy. Everything downstream of that termination point — the WAF, the internal load balancer, the sidecar that does rate limiting, the application itself, and every observability pipeline that touched the request — saw the decrypted body. The channel was encrypted. The data was not.
She files a P1 incident at 11:41 PM. The remediation discussion that follows includes a sentence that engineers who have shipped APIs at scale will recognize immediately: "We assumed TLS meant the data was protected."
It did not, and it never does.
This is the trap that trips experienced teams. TLS is a transport-layer guarantee. It ensures that data in motion between two endpoints cannot be read or modified by a third party positioned on the network between those endpoints. It says nothing about what any endpoint does with the data once it receives it — and in a modern SaaS architecture, "the endpoint" is not the application server. It is the first system to terminate the TLS session, which is almost always an edge proxy or CDN that the application team did not write and does not fully control.
The incident review takes three hours. The team audits every downstream system that received the affected requests: a Cloudflare Workers proxy that strips certain headers, a Kong gateway with request logging enabled, an ELK stack ingesting application logs from all three availability zones, a Sentry project whose beforeSend hook had a masking rule that matched password as a key name but not new_password, and a PagerDuty integration that was receiving the full request context on every 5xx alert. Each of these systems had appropriate access controls. None of them were breached. None of them were supposed to see cleartext passwords. All of them did.
The forensic scope determination is the worst part of the meeting. The question is not "who broke in?" It is "how many users' passwords were visible to how many internal systems, for how long, and do any of those systems have log retention that makes those passwords retrievable today?" The answer requires weeks to fully determine.
We had mTLS between every internal service, dedicated log encryption at rest, strict RBAC on the log aggregator — and none of it helped, because the plaintext was already there before any of those controls applied.
The fix is not "use better TLS configuration." It is not "add a masking rule to the log shipper." Both of those are mitigations against the symptom; neither addresses the root cause. The root cause is that sensitive field values were placed into the request body in cleartext and handed to the TLS layer, which handed them in cleartext to the first proxy, which handed them to everything downstream.
The fix is encrypting sensitive field values before they leave the client, so that the only system that can read them is the one designed and authorized to do so. That is what end-to-end encryption means in an API context: the client is one end, the application server is the other end, and every intermediate component — however well-intentioned, however carefully configured — is outside the protected channel.
This post is a detailed, honest account of how to build that correctly. It covers the protocol design with concrete Node.js pseudo-code, the failure modes that appear in otherwise well-reviewed implementations, the performance characteristics you can actually expect, and the audit evidence that distinguishes a system that is genuinely secure from one that looks correct until a security engineer sits down with Burp Suite at 11:32 PM.
What's actually happening
To understand why the standard advice ("use HTTPS everywhere") is insufficient for protecting sensitive request bodies, you need a precise model of where TLS terminates and what happens after.
TLS terminates at the edge
In a bare-metal deployment with a single server, TLS might terminate at your application process and nowhere else. In every realistic SaaS production environment, it terminates earlier. A typical architecture for a production API looks like this:
Client
│
│ TLS (client ↔ CDN/edge proxy)
▼
CDN / Edge Proxy ← TLS terminates here
│
│ Plaintext (or re-encrypted with internal cert)
▼
WAF / DDoS filter
│
│ Plaintext
▼
Internal load balancer
│
│ Plaintext
▼
Rate-limiting sidecar / service mesh
│
│ Plaintext
▼
Application serverEven if every hop after the CDN uses mTLS internally, the plaintext is accessible to every component at every hop. The WAF has to see the plaintext body to inspect it. The rate limiter has to see enough of the body to make routing decisions. The load balancer access log will, by default, record the request URI and often the body.
This is not a configuration error. It is the designed behavior of a layered network architecture. Each layer needs to read the traffic to do its job.
What this means for sensitive fields
A password reset token, a raw credit card PAN, a Social Security number, a medical record identifier — any field with regulatory or security significance that travels in the request body is exposed to every layer in that stack. It is also exposed to:
- Log aggregators: Application logs frequently include request bodies for debugging. Even with masking rules, masking rules have edge cases and are not always applied consistently across all log shippers.
- Error trackers: When an exception is thrown mid-request, error tracking tools like Sentry or Datadog APM capture the current request context, which includes the body. The exception does not have to be in the sensitive-field handler — it can be anywhere downstream.
- Distributed tracing systems: Trace spans sometimes include request attributes. Whether body fields are captured depends on instrumentation configuration that evolves over time.
- Audit logs: Some compliance configurations explicitly log full request bodies for audit purposes, which is the right behavior for most fields and catastrophic for a few sensitive ones.
The threat model is not an external attacker intercepting packets. It is the aggregate surface area of internal systems, misconfigured tooling, and human error in log handling.
Why field-level encryption alone is not enough
A common first response to this threat model is to add field-level encryption: the client encrypts the password value before placing it in the JSON body, the server decrypts it. This is directionally correct but incomplete without answering two questions: under what key, and with what binding?
If you encrypt the field under a long-term server public key — a key that was embedded in the client at build time or fetched once at application load — you have an encryption scheme but not a forward-secrecy property. An attacker who compromises the server's long-term private key can decrypt every recorded ciphertext from any point in the past. For a logging proxy that has been archiving encrypted request bodies, the entire archive becomes readable retroactively.
Forward secrecy is the property that compromise of a long-term key does not compromise past session keys. It is a formal requirement in NIST SP 800-56A Rev 3, which governs key-establishment schemes for federal use and is the de facto reference for production key agreement design. The mechanism for achieving forward secrecy in a public-key context is ephemeral key exchange: each session (or each request) generates a fresh keypair, performs the exchange, and discards the private key immediately after deriving the session key. Even if the server's long-term identity key is later compromised, there is no retained private key material that could decrypt past sessions.
Why JWE alone does not solve the session-binding problem
RFC 7516 (JSON Web Encryption) is the JOSE standard for representing encrypted content as a JSON structure. JWE is a format, not a protocol. It specifies how to represent a ciphertext, which algorithm was used, and relevant key-management information in a compact or JSON serialization. What it does not specify is how the encryption key was established, whether that establishment was forward-secret, or how the encrypted object is bound to a specific session or request context.
A system that encrypts request bodies with JWE using ECDH-ES+A256GCM achieves authenticated encryption and, if ephemeral keys are used, forward secrecy at the key-agreement layer. But it still leaves open the question of binding: can a ciphertext that was legitimately created for request A be replayed as request B? JWE's compact serialization includes a protected header and optionally additional authenticated data (AAD). Binding the ciphertext to a specific session, method, and path requires putting that context into the AAD — and the JWE spec leaves the AAD content entirely to the application. Most off-the-shelf JWE integrations leave AAD empty.
The combination of session binding, forward secrecy, and authenticated encryption is what makes a protocol audit-ready. JWE can be part of the implementation, but the protocol design has to provide the rest.
The cryptographic primitives
The protocol described in this post uses three standard primitives, each governed by a current IETF or NIST reference:
X25519 (Curve25519 ECDH): RFC 7748 specifies X25519 as a Diffie-Hellman function over Curve25519. X25519 takes two 32-byte values — a private scalar and a public Montgomery point — and produces a 32-byte shared secret. The curve was designed by Daniel J. Bernstein with a specific focus on implementation safety: it has no special points that require branch logic, the private key scalar is clamped rather than validated, and cofactor clearing is built into the function. The 32-byte output of X25519 is not directly usable as an encryption key — it is high-entropy but potentially low in uniform distribution near the curve, so a key derivation step is always required.
HKDF-SHA256: RFC 5869 specifies HMAC-based Key Derivation Function. HKDF takes an input key material (in our case, the X25519 output), an optional salt, and an info string, and produces a pseudorandom key of the requested length. The info string is the mechanism for domain separation: two calls to HKDF with the same IKM but different info strings produce independent, uncorrelated keys. We use the info string to bind the derived key to the specific session and both public keys involved in the exchange, preventing key reuse across sessions.
AES-256-GCM: NIST SP 800-38D specifies Galois/Counter Mode as an authenticated encryption with associated data (AEAD) scheme. RFC 5116 provides the AEAD interface that GCM implements. AES-256-GCM takes a 32-byte key, a 12-byte nonce (IV), plaintext, and additional authenticated data (AAD). It produces a ciphertext of the same length as the plaintext plus a 16-byte authentication tag. The authentication tag covers both the ciphertext and the AAD, which means any modification to either will cause decryption to fail. The AAD is authenticated but not encrypted — this is the mechanism for binding context (session ID, HTTP method, path) to a ciphertext without including that context in the encrypted body.
The regulatory and compliance context
This threat model is not academic. Several regulatory frameworks now require or strongly imply field-level or payload-level encryption for certain data categories, independent of transport encryption.
PCI DSS v4.0 Requirement 4.2.1 mandates that "all transmission of cardholder data over open, public networks" be secured with "strong cryptography." The guidance notes that this applies to "the entire transmission path," which regulators have interpreted as inclusive of application-layer processing paths, not merely the network transport. The phrase "strong cryptography" has a defined meaning in the PCI glossary that includes authenticated encryption — plain AES-CBC does not qualify.
HIPAA's Technical Safeguards (45 CFR §164.312(e)(1)) require covered entities to implement "technical security measures to guard against unauthorized access to electronic protected health information that is being transmitted over an electronic communications network." The term "transmitted" is not limited to the TCP/TLS hop; it includes the full data path from originator to ultimate recipient. Covered entities have been found to be in violation for PHI appearing in application logs even when TLS was in use.
GDPR Article 32 requires "appropriate technical and organisational measures" for processing personal data, explicitly naming "encryption of personal data" as an example. Supervisory authorities in several EU member states have issued guidance distinguishing transport encryption (necessary but not sufficient) from encryption of the data itself.
Meeting the letter of these requirements does not require application-layer encryption in every case. It does require that you can demonstrate — to an auditor, with evidence — that sensitive data is protected throughout its processing path, not only in transit. Application-layer encryption is the most straightforward way to provide that demonstration.
Forward secrecy and the key agreement lifecycle
A session key derived from an ephemeral X25519 exchange achieves forward secrecy if and only if the server's ephemeral private key is deleted immediately after the key derivation step. This is not automatic — it requires explicit design. In practice, the server needs to retain the ephemeral private key long enough to process the first authenticated request in the session (or the session establishment frame), and then discard it. Once discarded, the session is protected: even if the server's long-term identity key and the entire session key store are compromised, the ECDH private key is gone, and no amount of retained ciphertext can be decrypted.
The implication for session architecture is that ephemeral private keys must not be written to persistent storage. They live in process memory for a bounded window (the session establishment window), are used to derive the session key, and are then zeroed and garbage-collected. The session key can be stored (with appropriate access controls and TTL) because it is derived material — knowing the session key does not reveal the ECDH private key, and knowing the ECDH private key after it has been used does not reveal anything about past derived keys if HKDF's one-wayness holds.
Scope: which fields need this treatment
Not every field in every API call needs application-layer encryption. Adding encryption overhead to paginated list responses or status-check endpoints would impose cost without benefit. The fields that warrant this treatment are those that, if they appeared in a log or an error report, would create a regulatory, legal, or security problem:
- Raw credentials (passwords, password reset tokens, session tokens that provide authentication)
- Payment card data (PAN, CVV, expiry — though ideally these never travel through your servers at all and go directly to a payment processor)
- Government-issued identifiers (SSN, national ID numbers, passport numbers)
- Healthcare record identifiers or diagnostic codes
- Biometric data
- Any field explicitly listed as "sensitive" in your data classification policy
A practical approach is to mark sensitive fields at the schema level and have the client-side encryption layer apply automatically to any field carrying that mark. This avoids the fragile pattern of manually auditing every endpoint to determine which fields need encryption.
The pattern
Before committing to a specific implementation, it is worth surveying the design space. There are four distinct patterns for API-layer encryption, each with different tradeoffs.
Pattern 1: Per-request asymmetric encryption
The simplest pattern. The server publishes a static public key (RSA-OAEP or ECIES). For each sensitive request, the client encrypts the payload under that public key. The server decrypts with the corresponding private key.
Strengths: No session state. No handshake. Conceptually simple. Works for stateless APIs and serverless functions.
Weaknesses: No forward secrecy (unless you rotate the keypair per request, which adds complexity). Asymmetric decryption is computationally expensive at scale — RSA-OAEP decryption is roughly 100x more expensive than AES-GCM decryption on the same hardware. No replay protection without additional mechanism. The static public key can be extracted from a client build and used to encrypt arbitrary payloads.
When to use: Low-volume APIs where simplicity is the priority and forward secrecy is not a hard requirement. Prototyping. Cases where the session concept does not apply.
Pattern 2: Per-session symmetric after handshake
The canonical pattern and the one this post details. The first request in a session is a handshake: both sides perform an ephemeral X25519 exchange, derive a shared symmetric key via HKDF, and use that key for all subsequent requests in the session. The symmetric key is fast (AES-GCM is hardware-accelerated on modern CPUs via AES-NI instructions), the handshake is inexpensive (X25519 scalar multiplication is cheap), and the session model maps naturally onto API design patterns.
Strengths: Forward secrecy with ephemeral keys. Fast symmetric encryption after handshake. Session-bound — keys are scoped to a specific client/server pair. Replay protection via nonce management. Well-audited primitives.
Weaknesses: Requires server-side session state (the session key indexed by session ID). Session state must be replicated across server instances in a multi-region deployment. Key rotation requires session re-establishment. Session expiry must be enforced to bound the exposure window of any given session key.
When to use: Any API that handles sensitive data at volume. This is the right default.
Pattern 3: Envelope encryption with KMS
In regulated environments — healthcare, finance, government — private key material must reside in a Hardware Security Module (HSM) or a cloud KMS (AWS KMS, GCP Cloud KMS, Azure Key Vault). This constraint changes the architecture: the HSM cannot hold per-session keys efficiently, so a two-layer approach is used. A Data Encryption Key (DEK) is generated in process memory, used to encrypt the payload, and then itself encrypted by the KMS using a Key Encryption Key (KEK) that never leaves the HSM.
AWS KMS documents this pattern explicitly as "envelope encryption." The application generates a 256-bit DEK locally, encrypts the payload with AES-256-GCM, calls kms:GenerateDataKey or kms:Encrypt to protect the DEK, and stores the encrypted DEK alongside the ciphertext. Decryption calls kms:Decrypt to recover the DEK, then decrypts the payload locally.
Strengths: Key material for the KEK never exists in process memory. HSM provides tamper evidence and access audit trails. Satisfies compliance requirements that mandate HSM key custody.
Weaknesses: Adds latency proportional to KMS round-trip time (typically 1–5 ms for AWS KMS in the same region, but this adds up at high request volumes). Adds cost per KMS API call. Adds operational complexity. The forward-secrecy argument requires careful reasoning — if the HSM retains the KEK indefinitely, a compromise of the HSM retroactively compromises all stored encrypted DEKs.
When to use: When a compliance requirement explicitly mandates HSM-backed key custody, or when key rotation and audit logging at the HSM level are required by policy.
Pattern 4: Client-pinned public key vs. rotating handshake key
A variation on Pattern 2: rather than publishing a server ephemeral key via a handshake endpoint, the server's public key is pinned in the client at build time (similar to certificate pinning). This eliminates the handshake roundtrip but creates a key rotation problem: updating the pinned key requires a client release.
Rotating handshake keys — where the server publishes a fresh ephemeral public key for each session initiation — provide forward secrecy at the cost of the handshake roundtrip. The handshake roundtrip is typically 10–50 ms on a single-region deployment (dominated by network latency), which is acceptable for session establishment but not for per-request overhead.
The right choice depends on key rotation requirements. For most APIs, rotating handshake keys are preferable because they provide forward secrecy and allow the server to rotate keys on its own schedule without coordinating client releases.
Replay protection
Any encrypted payload that is not bound to a specific request instance can be replayed. An attacker who captures an encrypted password reset request can replay it as many times as the server allows. Replay protection requires at minimum a nonce (a value the server has not seen before) bound to the ciphertext. In AES-GCM, the 12-byte nonce is already required for correctness — reusing a nonce with the same key is catastrophic and breaks the authentication guarantee. But a nonce that the client generates and the server does not track provides no replay protection; the server will happily accept the same nonce multiple times.
Correct replay protection requires the server to maintain a nonce registry: a store of nonces seen within the session, with entries expiring after the session window. A simpler approach is a monotonically incrementing counter: the client uses nonce = 0 for the first request, nonce = 1 for the second, and so on. The server accepts only nonces that are strictly greater than the last seen nonce for that session. This is simpler than a full registry and provides equivalent replay protection within a session.
AAD design
Additional Authenticated Data (AAD) is the mechanism for binding a ciphertext to its context. If a ciphertext encrypting a PATCH /user/profile body can be used as the body of a POST /admin/reset-password request, the encryption provides confidentiality but not integrity over the request semantics. AAD should contain at minimum: the session ID, the HTTP method, and the normalized request path. It should not contain values that will differ between encryption and decryption due to normalization differences (query parameter ordering, trailing slashes, case).
Session lifetime tradeoffs
Longer sessions mean fewer handshakes and lower overhead but a larger exposure window for a compromised session key. Shorter sessions mean more frequent handshakes but a smaller key compromise radius. A session lifetime of 15–60 minutes covers the typical interactive API session and limits the damage of a key compromise to a narrow window. Sessions should also be invalidated on explicit logout, on authentication events, and on detection of nonce anomalies.
The fix / approach
The following is a complete protocol specification for per-session X25519 + AES-256-GCM API encryption. The pseudo-code is written in Node.js using the node:crypto module, which provides all necessary primitives in Node 20+.
Protocol overview
The protocol has two phases:
-
Handshake: Server generates ephemeral X25519 keypair and publishes the public key. Client generates its own ephemeral X25519 keypair. Both sides perform ECDH to derive a shared secret. Both sides derive the session key via HKDF. Server stores the session key indexed by session ID; client holds the session key in memory.
-
Encrypted requests: Client encrypts sensitive request bodies with AES-256-GCM using the session key and a per-request nonce. The AAD binds the ciphertext to the session, method, and path. Server decrypts on receipt.
Server generates ephemeral X25519 keypair
When a client initiates a session, the server generates a fresh X25519 keypair. The private key is held in memory and associated with the session ID. It is never written to persistent storage and is deleted immediately after the key derivation step.
import { createECDH, randomBytes } from 'node:crypto'; function generateServerEphemeralKeypair(): { publicKey: Buffer; privateKey: Buffer; } { const ecdh = createECDH('X25519'); ecdh.generateKeys(); return { publicKey: ecdh.getPublicKey(), // 32 bytes privateKey: ecdh.getPrivateKey(), // 32 bytes }; } // In the handshake handler: const sessionId = randomBytes(16).toString('hex'); // 32 hex chars const { publicKey: serverPubKey, privateKey: serverPrivKey } = generateServerEphemeralKeypair(); // Store ONLY the private key in a short-lived in-memory map, // keyed by sessionId. Do NOT write to Redis or DB yet. // The private key is needed for at most one HKDF derivation. pendingHandshakes.set(sessionId, { serverPrivKey, expiresAt: Date.now() + 30_000, // 30-second window to complete handshake });The server responds with:
{ "session_id": "a1b2c3d4e5f6...", "server_public_key": "<base64url-encoded 32-byte X25519 public key>", "algorithm": "X25519-HKDF-SHA256-AES-256-GCM" }Client generates ephemeral X25519 keypair and computes shared secret
The client generates its own ephemeral X25519 keypair. It decodes the server's public key, performs the ECDH scalar multiplication, and obtains the 32-byte shared secret. The client's private key is discarded immediately after this step.
import { createECDH } from 'node:crypto'; async function clientHandshake(serverPublicKeyB64: string): Promise<{ sessionKey: Buffer; clientPublicKey: Buffer; }> { const ecdh = createECDH('X25519'); ecdh.generateKeys(); const clientPublicKey = ecdh.getPublicKey(); // 32 bytes const serverPublicKey = Buffer.from(serverPublicKeyB64, 'base64url'); // ECDH: scalar multiplication of client private key × server public point const sharedSecret = ecdh.computeSecret(serverPublicKey); // 32 bytes // Private key is no longer needed. In a long-lived process, explicitly zero it. // (Node.js does not expose a guaranteed zero-on-free API; use a Uint8Array // and fill(0) as a best-effort measure.) ecdh.getPrivateKey().fill(0); const sessionKey = await deriveSessionKey( sharedSecret, serverPublicKey, clientPublicKey, sessionId, // from the server response ); return { sessionKey, clientPublicKey }; }Both sides derive the session key via HKDF-SHA256
The 32-byte ECDH output is not directly usable as an AES key — it must be run through HKDF to ensure uniform distribution and to bind the key to the specific session context. The HKDF info string encodes the session ID and both public keys. This means a session key derived for session A cannot be reused for session B, even if the same ephemeral keypairs were somehow reused (which they should not be, but defense in depth applies).
import { hkdfSync } from 'node:crypto'; function deriveSessionKey( sharedSecret: Buffer, // 32-byte X25519 output serverPublicKey: Buffer, // 32-byte server ephemeral public key clientPublicKey: Buffer, // 32-byte client ephemeral public key sessionId: string, // hex string ): Buffer { // HKDF info string encodes the binding context. // Format: "x25519-aes-gcm-v1" + session_id + server_pub + client_pub // This construction ensures that the derived key is unique to this // specific handshake exchange and cannot be confused with any other. const info = Buffer.concat([ Buffer.from('x25519-aes-gcm-v1'), Buffer.from(sessionId, 'hex'), serverPublicKey, clientPublicKey, ]); // HKDF-SHA256: salt = undefined (HKDF will use a zero-filled salt of HashLen), // IKM = sharedSecret, info = binding context, length = 32 bytes (AES-256 key) const derived = hkdfSync( 'sha256', sharedSecret, '', // salt — empty string produces a zero-byte salt, which is valid per RFC 5869 §3.1 info, 32, ); return Buffer.from(derived); }After key derivation, the server stores the derived session key in the session store and immediately discards the ephemeral private key:
// Server side: complete the handshake const pending = pendingHandshakes.get(sessionId); if (!pending || Date.now() > pending.expiresAt) { throw new Error('Handshake session expired or not found'); } const sharedSecret = serverECDH.computeSecret(clientPublicKey); // Zero the private key from the pending map entry pending.serverPrivKey.fill(0); pendingHandshakes.delete(sessionId); // Remove from in-memory map const sessionKey = deriveSessionKey( sharedSecret, serverPubKey, clientPublicKey, sessionId, ); // Store only the derived session key, with TTL await sessionStore.set(`session:${sessionId}`, { key: sessionKey.toString('base64'), clientPubKey: clientPublicKey.toString('base64url'), createdAt: Date.now(), expiresAt: Date.now() + SESSION_TTL_MS, requestNonce: 0, // monotonic counter for replay protection responseNonce: 0, });Client encrypts the request body with AES-256-GCM
For each request containing sensitive data, the client generates a fresh 12-byte nonce, encrypts the body, and binds the ciphertext to the request context via AAD.
import { createCipheriv, randomBytes } from 'node:crypto'; interface EncryptedPayload { ciphertext: string; // base64url nonce: string; // base64url, 12 bytes tag: string; // base64url, 16 bytes (GCM auth tag) aad: string; // base64url (also sent in header for server verification) } function encryptRequestBody( sessionKey: Buffer, // 32-byte AES-256 key plaintextBody: object, sessionId: string, method: string, // e.g. "POST" path: string, // e.g. "/api/v1/auth/reset-password" requestNonce: number, // monotonic counter, client-managed ): EncryptedPayload { const nonce = randomBytes(12); // 96-bit IV for AES-GCM // AAD: session binding + request semantics + nonce counter // This prevents the ciphertext from being replayed on a different // endpoint or with a different method. const aadObject = { sid: sessionId, method: method.toUpperCase(), path: normalizePath(path), n: requestNonce, }; const aad = Buffer.from(JSON.stringify(aadObject)); const cipher = createCipheriv('aes-256-gcm', sessionKey, nonce); cipher.setAAD(aad); const plaintext = Buffer.from(JSON.stringify(plaintextBody)); const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); const tag = cipher.getAuthTag(); // 16 bytes return { ciphertext: ciphertext.toString('base64url'), nonce: nonce.toString('base64url'), tag: tag.toString('base64url'), aad: aad.toString('base64url'), }; } function normalizePath(path: string): string { // Remove trailing slash, lowercase, strip query string return path.replace(/\/$/, '').toLowerCase().split('?')[0]; }The encrypted request is sent as a JSON body:
{ "session_id": "a1b2c3d4e5f6...", "payload": { "ciphertext": "<base64url>", "nonce": "<base64url>", "tag": "<base64url>", "aad": "<base64url>" } }Server decrypts and verifies the request
The server retrieves the session key, reconstructs the AAD, and decrypts. If the authentication tag does not verify, or if the AAD does not match the actual request context, decryption fails and the request is rejected.
import { createDecipheriv } from 'node:crypto'; async function decryptRequestBody( sessionId: string, encryptedPayload: EncryptedPayload, method: string, path: string, ): Promise<object> { const session = await sessionStore.get(`session:${sessionId}`); if (!session) throw new AuthError('Session not found or expired'); if (Date.now() > session.expiresAt) { await sessionStore.del(`session:${sessionId}`); throw new AuthError('Session expired'); } const sessionKey = Buffer.from(session.key, 'base64'); const nonce = Buffer.from(encryptedPayload.nonce, 'base64url'); const ciphertext = Buffer.from(encryptedPayload.ciphertext, 'base64url'); const tag = Buffer.from(encryptedPayload.tag, 'base64url'); const aad = Buffer.from(encryptedPayload.aad, 'base64url'); // Reconstruct the expected AAD from the actual request context // and compare with the client-supplied AAD. // This is the request-binding check. const expectedAadObject = { sid: sessionId, method: method.toUpperCase(), path: normalizePath(path), n: session.requestNonce + 1, // next expected nonce }; const expectedAad = Buffer.from(JSON.stringify(expectedAadObject)); if (!aad.equals(expectedAad)) { throw new AuthError('AAD mismatch: request binding failed'); } // Verify nonce is exactly the next expected value (replay protection) const aadParsed = JSON.parse(aad.toString()); if (aadParsed.n !== session.requestNonce + 1) { throw new AuthError('Nonce replay or gap detected'); } const decipher = createDecipheriv('aes-256-gcm', sessionKey, nonce); decipher.setAAD(aad); decipher.setAuthTag(tag); let plaintext: Buffer; try { plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); } catch { // GCM authentication failure — ciphertext was tampered or key is wrong throw new AuthError('Decryption authentication failed'); } // Advance the nonce counter atomically (use conditional update in your store) await sessionStore.incrementNonce(`session:${sessionId}`, 'requestNonce'); return JSON.parse(plaintext.toString()); }A critical implementation note: the nonce counter increment must be atomic. In a distributed system, two concurrent requests with nonces N and N+1 could arrive simultaneously. The correct implementation uses a database transaction or a Redis INCR operation with a conditional check, not a read-modify-write pattern that creates a TOCTOU race.
Server encrypts the response with the same session key
Responses to encrypted requests should also be encrypted, using the server's response nonce counter (separate from the request nonce counter).
function encryptResponse( sessionKey: Buffer, responseBody: object, sessionId: string, responseNonce: number, ): EncryptedPayload { const nonce = randomBytes(12); const aadObject = { sid: sessionId, dir: 'response', n: responseNonce, }; const aad = Buffer.from(JSON.stringify(aadObject)); const cipher = createCipheriv('aes-256-gcm', sessionKey, nonce); cipher.setAAD(aad); const plaintext = Buffer.from(JSON.stringify(responseBody)); const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); const tag = cipher.getAuthTag(); return { ciphertext: ciphertext.toString('base64url'), nonce: nonce.toString('base64url'), tag: tag.toString('base64url'), aad: aad.toString('base64url'), }; }Session expiry, key rotation, and teardown
Sessions must have an enforced TTL. When a session expires, the session key is deleted from the store. Key rotation — establishing a new session with a new handshake — should be triggered either on expiry, on explicit user action (such as a privilege escalation), or after a configurable number of requests.
// Session expiry: enforce in middleware, not just at decryption time async function validateSession(sessionId: string): Promise<SessionRecord> { const session = await sessionStore.get(`session:${sessionId}`); if (!session) throw new AuthError('Session not found'); if (Date.now() > session.expiresAt) { await sessionStore.del(`session:${sessionId}`); throw new AuthError('Session expired — re-establish handshake'); } return session; } // Explicit session teardown on logout async function teardownSession(sessionId: string): Promise<void> { const session = await sessionStore.get(`session:${sessionId}`); if (session) { // Zero the key material before deleting (best effort in Node.js) const keyBuf = Buffer.from(session.key, 'base64'); keyBuf.fill(0); await sessionStore.del(`session:${sessionId}`); } } // Constants for session management const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes const MAX_REQUESTS_PER_SESSION = 10_000; // rotate after N requests const HANDSHAKE_WINDOW_MS = 30_000; // 30 seconds to complete handshakeKey rotation is handled by the client initiating a new handshake before the session expires. The server should return a
X-Session-Expires-Atheader in encrypted responses so the client can proactively rotate rather than discovering expiry mid-request.
Anti-patterns
These are the mistakes that appear most frequently in production implementations. Each is paired with the correct approach.
Nonce reuse
Wrong
Right
AES-GCM nonce reuse with the same key is catastrophic. It allows an attacker who observes two ciphertexts encrypted with the same key and nonce to XOR the ciphertexts and recover the XOR of the plaintexts — and from that, eventually recover the authentication key used for GCM's GHASH function, which means they can forge authentication tags. This is not a theoretical concern; it has been demonstrated in practice against GCM implementations that use counters incorrectly.
AES-CBC + manual HMAC
The pre-AEAD pattern of encrypting with AES-CBC and then authenticating with HMAC-SHA256 is still found in production code. It is error-prone: the "Encrypt-then-MAC" composition order matters (MAC the ciphertext, not the plaintext), the HMAC key must be independent of the encryption key (derive both from HKDF), and the MAC must cover all relevant context including the IV. Getting any of these wrong creates vulnerabilities. AES-256-GCM provides authentication as a property of the primitive; there is no composition to get wrong.
Shipping the long-term private key to the browser
If the server's private key — whether long-term or session-scope — is ever sent to a client (for example, as part of a debug response, included in a JS bundle, or stored in a browser database), the encryption scheme is broken. The server private key must never leave the server. The client's private key must never leave the client. The only values exchanged over the network are public keys and ciphertexts.
Omitting AAD
A ciphertext without AAD binds only to the session key. It can be replayed to any endpoint, with any HTTP method, in any session that shares the same key. Adding {session_id, method, path, nonce} to the AAD ensures that a ciphertext created for POST /auth/reset cannot be replayed as POST /admin/delete-user, even if the attacker somehow compromises a different session key that happens to share the same value.
Trusting client-supplied session IDs for key lookup
The session ID must be validated before it is used for key lookup. An attacker who can supply an arbitrary session ID and have the server look it up in the key store has a potential IDOR vulnerability. Session IDs should be high-entropy random values (128 bits minimum), should not encode any semantic information, and should be verified against the authenticated user's record before being used.
Wrong
Right
Mixing handshake and payload nonces
The nonce space for the ECDH handshake (the random bytes in the session ID, the ephemeral key generation) is logically separate from the nonce space for AES-GCM encryption. Mixing them — for example, using the session ID as an AES-GCM nonce — conflates two distinct concepts and creates subtle vulnerabilities. Keep them separate: use randomBytes(16) for session IDs, use randomBytes(12) or a counter for AES-GCM nonces.
Session keys in Redis without TTL
A session key stored in Redis without a TTL persists indefinitely unless explicitly deleted. A process crash, a deploy, or a network partition that prevents the application from deleting the key on logout leaves orphaned session keys in the store. Always set a TTL equal to your session lifetime, and use SETEX or SET ... EX rather than SET followed by EXPIRE (the two-command pattern has a race condition).
# Wrong: two commands, TOCTOU race
SET session:abc123 '{"key":"..."}'
EXPIRE session:abc123 1800
# Right: atomic
SET session:abc123 '{"key":"..."}' EX 1800Storing the session key in cleartext in the session store
The session key is the most sensitive value in this architecture. Storing it in cleartext in Redis or Postgres means that a compromise of the session store reveals all active session keys. The session key should itself be encrypted at rest using a KMS-managed data key, or at minimum protected by Redis AUTH and TLS on the Redis connection.
A practical pattern for session key protection at rest: use a service-level data key (a 256-bit AES-GCM key managed by your KMS) to encrypt each session key before writing it to Redis. The encrypted session key in Redis looks like any other ciphertext blob. On read, the service decrypts the session key using the KMS data key before using it. This is a simplified form of envelope encryption that does not require a per-session KMS call — the KMS data key is cached in process memory for a short window (typically 5 minutes) and refreshed via a background task.
// Simplified pattern for session key protection at rest
// kmsDataKey is cached in memory, refreshed periodically
async function writeSessionKey(
sessionId: string,
sessionKey: Buffer,
kmsDataKey: Buffer, // 32-byte AES-256 key, fetched from KMS and cached
): Promise<void> {
const nonce = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', kmsDataKey, nonce);
const encryptedKey = Buffer.concat([cipher.update(sessionKey), cipher.final()]);
const tag = cipher.getAuthTag();
const record = {
k: Buffer.concat([nonce, tag, encryptedKey]).toString('base64'),
exp: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS,
// ... other session metadata
};
await redis.set(`session:${sessionId}`, JSON.stringify(record), 'EX', SESSION_TTL_SECONDS);
}This pattern means a Redis compromise exposes encrypted session keys, not plaintext ones. An attacker with access to Redis but not the KMS cannot decrypt the session keys without the KMS data key, which requires either KMS API credentials or a compromise of the application process itself.
Logging decryption errors at debug level
Decryption failures — authentication tag mismatch, nonce replay, AAD mismatch — are security events. They should be logged at warn or error level with the session ID and a timestamp, and they should trigger rate-based alerting. Logging them at debug level (or worse, silently ignoring them) means an active attack — a replay attempt, a session hijacking attempt, a payload tampering attempt — passes through your infrastructure without any signal. The decrypted payload must not be logged, but the failure event and its context must be.
Case studies / numbers
The following observations are drawn from composite integrations and do not represent a single deployment. Numbers are scoped as observations from specific configurations.
Handshake latency on a single-region deployment
On an integration using a Node.js 20 API server, a single-region Postgres-backed session store, and clients sending from the same cloud region over an internal VPC, we observed the following handshake latencies (measured client-side from request initiation to session key available):
- p50: 14 ms
- p95: 28 ms
- p99: 41 ms
- p99.9: 87 ms
The dominant component of handshake latency was the Postgres round-trip for writing the pending handshake state (~6 ms p50) and the second Postgres write for committing the derived session key (~5 ms p50). The X25519 scalar multiplication and HKDF derivation together were sub-millisecond on both client and server.
For comparison, a TLS 1.3 handshake on the same network typically completes in 8–12 ms (one full round-trip for the initial handshake, with 0-RTT available for resumption). Our application-layer handshake adds roughly one full round-trip of overhead over TLS alone, which is acceptable for session establishment — it is not done per request.
Encryption overhead per request
After session establishment, the per-request overhead of AES-256-GCM encryption in Node.js on a modern CPU (Intel Ice Lake with AES-NI) was measured at:
- 1 KB payload: < 0.1 ms
- 10 KB payload: < 0.2 ms
- 100 KB payload: ~0.8 ms
- 1 MB payload: ~6 ms
These numbers are consistent with hardware-accelerated AES-NI throughput. AES-GCM at this payload size range is not a performance bottleneck; the bottleneck is almost always network I/O or database queries.
Session store scale: Redis vs. Postgres
In a test deployment processing 2,000 requests per second with an average session lifetime of 20 minutes:
- Active session count at steady state: approximately 40,000 sessions (assuming 10 requests per session per minute on average, this is a rough upper bound).
- Redis: each session record is approximately 200 bytes (key + session key + metadata). 40,000 sessions = ~8 MB of Redis memory. Negligible.
- Postgres: at 40,000 rows in a sessions table with appropriate indexing, read latency for session key lookup was ~2 ms p50. Write latency for nonce counter increment was ~3 ms p50 (row-level lock on the update).
The Postgres nonce counter increment requires a row-level lock (UPDATE ... WHERE session_id = $1 AND request_nonce = $2 RETURNING request_nonce) to prevent concurrent requests from using the same nonce. At high concurrency, this becomes a bottleneck. The recommended pattern for high-concurrency deployments is to use Redis with INCR for the nonce counter and Postgres for durable session metadata, or to use Redis for both with appropriate persistence configuration.
Key rotation in a zero-downtime deploy
A concern with session-based encryption is how to handle key rotation during a rolling deploy. If the server rotates its session store encryption keys during a deploy, sessions established before the deploy can fail to decrypt after it. The standard mitigation is a key version prefix in the session ID: the session ID encodes which key version was active when the session was established, and the server maintains a read keyring of current and previous key versions for a grace period (typically one deploy cycle). New sessions use the current key version; existing sessions use the version encoded in their session ID.
// Session ID format: v<keyVersion>-<randomHex>
// Example: "v3-a1b2c3d4e5f6..."
function generateSessionId(keyVersion: number): string {
return `v${keyVersion}-${randomBytes(16).toString('hex')}`;
}
function parseKeyVersion(sessionId: string): number {
const match = sessionId.match(/^v(\d+)-/);
if (!match) throw new Error('Invalid session ID format');
return parseInt(match[1], 10);
}Integration with an API gateway
On an integration where the API gateway (Kong, in this case) was configured to log full request bodies for audit compliance, the encryption scheme produced exactly the desired audit behavior: request body logs showed the encrypted payload structure (ciphertext, nonce, tag, AAD) but not the plaintext of sensitive fields. The session ID was visible in logs, which was acceptable — knowing a session ID without the session key provides no access to the plaintext. The AAD was also visible in base64url encoding, which was intentional: the request binding context (method, path, session ID, nonce) is not sensitive and its visibility in logs aids debugging.
The first time we walked an auditor through a log entry with the encrypted body, they asked us to prove we could actually decrypt it in a staging environment before they'd sign off. Which is exactly the right question to ask.
A practical detail from this integration: the WAF needed to be updated to not attempt to inspect the encrypted body fields. A WAF rule that looks for SQL injection patterns in request body fields will trigger false positives on base64url-encoded ciphertext. The solution was to configure the WAF to inspect the AAD field (which is visible and contains the method, path, and session ID — all valid inputs for WAF rules) and to skip body inspection for routes that use the encrypted payload format. This required coordination with the platform team but was straightforward once the WAF policy was understood.
Multi-region session state replication
On a deployment spanning two cloud regions with active-active traffic routing, the session store replication became a point of contention. The options considered were:
Option A — Regional affinity: Route all requests from a given client to the same region using the session ID as a consistent hash key. The session key lives only in that region's store. Simple, but creates single-region dependency and increases latency for clients geographically closer to the other region.
Option B — Synchronous replication: Every session write is synchronously replicated to both regions before acknowledging. Strong consistency, but adds replication latency (typically 30–80 ms cross-region) to every session establishment and nonce counter update.
Option C — Asynchronous replication with grace window: Session writes are acknowledged after local write; replication is asynchronous. A short grace window (5 seconds) allows the replica to catch up before the session is used. Suitable for session establishment (the handshake latency already includes at least one round-trip), less suitable for per-request nonce counter updates.
The deployed solution used Option A for the nonce counter (regional affinity via a sticky session cookie encoding the region) and Option C for session metadata (asynchronous replication for durability). This meant nonce counter updates were always local (sub-millisecond), while session metadata was eventually consistent across regions for recovery purposes.
Browser client implementation notes
The same protocol runs in the browser using the Web Crypto API. crypto.subtle.generateKey produces an X25519 keypair; crypto.subtle.deriveBits performs the ECDH scalar multiplication; crypto.subtle.importKey + crypto.subtle.deriveKey handles HKDF; crypto.subtle.encrypt handles AES-GCM. The Web Crypto API is available in all modern browsers (Chrome 37+, Firefox 34+, Safari 11+) and in all environments with a Secure Context (HTTPS or localhost).
The Web Crypto API enforces non-extractability by default: keys created with extractable: false cannot be exported from the crypto subsystem, which provides some protection against JavaScript-level exfiltration of key material. For session keys that are stored across page loads (for example, in IndexedDB for persistent sessions), non-extractable keys cannot be stored directly — the key must be wrapped (encrypted) before storage and unwrapped on retrieval. The Web Crypto API's wrapKey and unwrapKey operations support this pattern.
One caveat: the Web Crypto API is asynchronous, and X25519 key generation in the browser adds a small but measurable latency on mobile devices (observed 2–5 ms on mid-range Android hardware, sub-1 ms on modern iPhone hardware). This is still well within acceptable bounds for session establishment.
Limits + caveats
This protocol provides end-to-end confidentiality and integrity for request bodies between a client and a server, with forward secrecy from ephemeral key exchange. That is a meaningful and auditable security property. It is not a comprehensive security solution.
What this does not solve
Trust on first connect: The protocol assumes the client can securely obtain the server's handshake endpoint and trust the public key returned. If the first connection to the handshake endpoint is intercepted (by a malicious proxy or a BGP hijack), an attacker can perform a man-in-the-middle attack by substituting their own public key. This is the same problem TLS's certificate infrastructure solves at the transport layer. Application-layer encryption does not replace TLS — it operates in addition to TLS. The combination provides defense in depth: an attacker needs to break both TLS and the application-layer encryption to read sensitive fields.
Server compromise: If the server process is compromised while sessions are active, the attacker has access to the session key store and can decrypt any in-flight or recently received payload. The session key store is the critical asset. HSM-backed envelope encryption (Pattern 3) reduces but does not eliminate this risk, because an attacker with server-process-level access can still call the KMS API through the compromised process's credentials.
Client compromise: If the client device or process is compromised, the session key is accessible in memory. Secure Enclave or TEE-backed key storage on mobile platforms can reduce but not eliminate this risk. A compromised browser tab has access to whatever is in that tab's JavaScript memory, which includes the session key if it was imported as an extractable CryptoKey or stored in a JavaScript variable.
Side-channel attacks in browsers: JavaScript running in a browser shares a process with other code (extensions, other tabs in some scenarios) and is subject to timing side-channel attacks that have been demonstrated against cryptographic implementations. The Web Crypto API moves the implementation into browser native code, which is not accessible to JavaScript timing attacks in the same way, but browser isolation remains an active research area. Spectre-class attacks that exploit CPU speculation have been demonstrated against browser sandbox isolation; mitigations are in place in all major browsers but the research continues.
Debugging blindness in production: Encrypting sensitive fields means that production log entries for those fields are opaque. A debugging session for a bug in the password reset flow requires a development environment where decryption is available. Plan for this: provide a development-only flag that logs decrypted payloads, ensure your test harness can decrypt payloads for assertion purposes, and document the debugging procedure explicitly. The operational cost of encrypted logs is real and must be managed. On one integration, the first major incident after deploying application-layer encryption was resolved 40% slower than the baseline because the on-call engineer could not read the request context in the log entries. This is a real cost that must be weighed and planned for.
Key material in core dumps: A process crash that generates a core dump may capture session keys from memory. Configure your production environment to disable core dumps for processes that handle session keys, or to write them to a location with restricted access and automatic cleanup. On Linux, prctl(PR_SET_DUMPABLE, 0) disables core dumps for the current process; on Kubernetes, you can configure securityContext to prevent core dump files from being written to shared volumes.
Quantum computing threat horizon: The security of X25519 depends on the computational difficulty of the elliptic curve discrete logarithm problem on Curve25519. A sufficiently large quantum computer running Shor's algorithm would break this assumption. NIST's post-quantum cryptography standardization project (FIPS 203, ML-KEM, finalized 2024) has produced quantum-resistant key encapsulation mechanisms. For most production systems in 2025, the quantum threat is not an immediate operational concern, but it is worth noting for systems that require security guarantees over multi-decade horizons.
Recovery scenarios
If a session key is suspected to be compromised (anomalous nonce behavior, security alert, unusual decryption failures), the correct response is immediate session invalidation. Delete the session record from the session store. The client will receive a session-not-found error on the next encrypted request and will re-establish the handshake. This provides a fast, low-damage recovery path. No long-term key rotation is required because each session key is ephemeral and scoped.
If the session store itself is suspected to be compromised, the response is to rotate all session keys by invalidating all active sessions (a full store flush) and forcing clients to re-establish. The damage radius is bounded by the TTL window — only sessions active at the time of the suspected compromise are affected.
On the detection side: nonce anomalies are a meaningful signal. A nonce counter that skips values, or a client that presents a nonce the server has already seen, indicates either a replay attempt or a bug in the client's nonce management. Build an alert on nonce validation failures (a rate above baseline should trigger investigation) and log the session ID and client IP for any nonce validation failure, even though you do not log the decrypted payload.
Sources & further reading
The following are the primary references used in this post. All RFCs are accessible at no cost via the IETF Datatracker.
RFC 7748 — Elliptic Curves for Security (Bernstein et al., 2016) Specifies X25519 and X448 Diffie-Hellman functions. Defines the wire format, the clamping procedure for private scalars, and the cofactor behavior. The canonical reference for X25519 usage. https://www.rfc-editor.org/rfc/rfc7748
RFC 5869 — HMAC-based Extract-and-Expand Key Derivation Function (Krawczyk & Eronen, 2010) Specifies HKDF. Covers the Extract step (converts IKM to a uniform PRK) and the Expand step (derives keys of arbitrary length). Section 3.2 covers the info string's role in domain separation. https://www.rfc-editor.org/rfc/rfc5869
RFC 5116 — An Interface and Algorithms for Authenticated Encryption (McGrew, 2008) Defines the AEAD interface that AES-GCM implements. Formally specifies the confidentiality and integrity properties that a correct AEAD implementation must provide. The reference for understanding what GCM's authentication tag covers. https://www.rfc-editor.org/rfc/rfc5116
RFC 7516 — JSON Web Encryption (Jones & Hildebrand, 2015) Specifies the JWE format. Relevant for understanding what JWE does and does not provide at the format level — it is a serialization format, not a complete key-agreement protocol. https://www.rfc-editor.org/rfc/rfc7516
NIST SP 800-56A Rev 3 — Recommendation for Pair-Wise Key-Establishment Schemes Using Discrete Logarithm Cryptography (2018) The NIST standard for key agreement. Covers requirements for ephemeral key generation, key derivation, binding context, and forward secrecy. The formal basis for the claim that ephemeral key exchange provides forward secrecy. https://csrc.nist.gov/publications/detail/sp/800-56a/rev-3/final
NIST SP 800-38D — Recommendation for Block Cipher Modes of Operation: Galois/Counter Mode (GCM) and GMAC (2007) Specifies AES-GCM. Covers the nonce requirements (Section 8.2 — nonces must be unique for a given key), the authentication tag length requirements, and the conditions under which GCM's security guarantees hold. The canonical reference for correct GCM usage. https://csrc.nist.gov/publications/detail/sp/800-38d/final
libsodium documentation — crypto_box and crypto_secretbox
libsodium's crypto_box implements X25519-XSalsa20-Poly1305, a similar design using a different stream cipher and MAC. The libsodium documentation's rationale sections provide clear explanations of why each primitive was chosen, which is useful for understanding the design space. https://libsodium.gitbook.io/doc/
Cloudflare Blog — "An overview of TLS 1.3" (Nick Sullivan, 2016) A practitioner-level explanation of where TLS terminates in CDN architectures and what the edge proxy sees. Relevant background for understanding the threat model in §2. https://blog.cloudflare.com/tls-1-3-overview-and-q-and-a/
AWS KMS — Envelope Encryption AWS's official documentation for the envelope encryption pattern. Describes how to use KMS-managed KEKs to protect application-generated DEKs, with explicit guidance on key hierarchies, caching, and IAM policy for KMS access. https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html#enveloping
Filippo Valsorda — "Curve25519 in Go" A deep-dive into the implementation of Curve25519 in Go's standard library, covering the constant-time requirements, the scalar clamping procedure, and the Montgomery ladder. Valuable for understanding why X25519 implementations can be safer than P-256 implementations in practice. https://filippo.io/a-literate-go-program-implementing-curve25519/
Signal Protocol Specification — Extended Triple Diffie-Hellman (X3DH) Signal's X3DH key agreement protocol is a production-grade example of ephemeral ECDH applied at scale, with formal security proofs for the forward secrecy and deniability properties. The session encryption protocol described in this post uses a subset of the same design patterns. https://signal.org/docs/specifications/x3dh/
Boneh & Shoup — "A Graduate Course in Applied Cryptography" (Chapter 9 — Authenticated Encryption) The rigorous treatment of AEAD security definitions, including the formal definition of nonce-respecting security and the conditions under which GCM is provably secure. Available free online. https://toc.cryptobook.us/
- Tech
Server-Sent Events vs WebSockets in 2026: when SSE wins, backpressure, reconnect semantics
A streaming LLM endpoint that worked on Chrome failed silently on Safari at 30 KB/s. The SSE vs WebSocket decision and the backpressure, reconnect, and heartbeat patterns that survive production.
- Tech
Webhook signature verification done right: HMAC, timing-safe compare, and replay defense
A webhook endpoint validated the signature with `===` and got owned by a side-channel attack three months later. The HMAC, timing-safe, and replay-window patterns that actually work.
- Tech
Postgres connection pooling at scale + serverless cold starts: pgbouncer, Supavisor, RDS Proxy
A serverless API that opened 4,200 idle Postgres connections during a Black-Friday burst. The pool-mode, prepared-statement, and transaction-scope choices that survive serverless.