hybrid-crypto-express
hybrid-crypto-express
Hybrid (RSA + AES-GCM) encryption for Node.js and Express ? zero dependencies, built on Node.js crypto. Lets APIs optionally encrypt request/response bodies per client using a short-lived session key established via a one-time RSA key exchange.
Table of contents
- Overview
- How it works
- Cryptography
- Installation
- Server setup
- Client usage
- API reference
- Request / response format
- HTTP headers
- Options & configuration
- Redis session store (multi-pod)
- Security considerations
- CORS
- Subpath imports
- License
Overview
- Purpose: Add optional end-to-end style encryption for HTTP JSON bodies between a client and an Express server, without changing your business logic. The server decrypts before your handlers and encrypts responses when the client asks for it.
- Model: Hybrid encryption ? the client gets the server?s RSA public key, generates a random AES-256 key, encrypts that key with RSA and sends it once (handshake). All later request/response bodies use that AES-256-GCM key (and a fresh IV per message).
- Session: The server stores the AES key per client ID with a configurable TTL (e.g. 5 minutes). After expiry the client must handshake again.
- Zero dependencies: Only Node.js built-in
cryptois used. - Runtime: Node.js ? 22 (see
package.jsonengines).
How it works
- Client calls
GET /api/crypto/public-keyand receives the server?s RSA public key (PEM). - Client generates a random 32-byte AES key, encrypts it with that public key (RSA-OAEP), and sends it in a handshake request with a stable client ID (e.g.
X-Client-ID: client_123). - Server decrypts the AES key with its RSA private key, stores it under that client ID with an expiry, and responds with success and
sessionExpiry. - For encrypted requests: client sends JSON body
{ ciphertext, iv, authTag }(AES-GCM ciphertext of the real payload) and headersX-Encrypted: trueandX-Client-ID: <id>. - Server (via
decryptRequestmiddleware) looks up the AES key for that client, decrypts the body, and setsreq.bodyto the decrypted object before your route runs. - For encrypted responses: when the same headers are present, the server (via
encryptResponsemiddleware) wrapsres.jsonso the body is encrypted as{ success, encrypted, ciphertext, iv, authTag, ... }and the client can decrypt with its stored AES key.
So: RSA is used only once per session to protect the AES key; all actual payloads use AES-256-GCM with a unique IV per message.
Cryptography
| Layer | Algorithm | Parameters / usage |
|---|---|---|
| Key exchange | RSA-OAEP | 2048-bit (configurable), SHA-256 |
| Payload crypto | AES-256-GCM | 12-byte IV, 128-bit auth tag per message |
| Session key | 256-bit random | One per client session, used only for AES-GCM |
- IVs are generated randomly per encrypt operation.
- Session keys require a pluggable
sessionStore(typicallyRedisSessionStore). There is no in-memory AES session store. See Redis session store (multi-pod).
Installation
npm install hybrid-crypto-express
Requirements: Node.js >= 22.0.0.
Server setup
- Create a
HybridCryptoinstance (optionally withsessionExpiryMsandrsaModulusLength). - Use
decryptRequest(hybridCrypto, options?)before any body parser so the middleware can read the raw body for encrypted requests. Skip paths like/health,/api/crypto/public-key,/api/crypto/handshakeso they are not treated as encrypted. - Use
express.json()/express.urlencoded()as usual. - Call
registerCryptoRoutes(app, hybridCrypto, options?)to mountGET /api/crypto/public-keyandPOST /api/crypto/handshake(and optionallyPOST /api/crypto/test). - Use
encryptResponse(hybridCrypto)so that when a request hasX-Encrypted: trueandX-Client-ID,res.json()will encrypt the body before sending.
Middleware order matters:decryptRequest ? express.json() ? your routes + registerCryptoRoutes ? encryptResponse (so it can wrap res.json).
Example:
const express = require('express');
const {
HybridCrypto,
decryptRequest,
encryptResponse,
registerCryptoRoutes
} = require('hybrid-crypto-express');
const app = express();
const hybridCrypto = new HybridCrypto({
sessionExpiryMs: 5 * 60 * 1000, // 5 minutes
rsaModulusLength: 2048
});
// 1) Decrypt encrypted requests (before body parser)
app.use(decryptRequest(hybridCrypto, {
skipPaths: ['/health', '/', '/api/crypto/public-key', '/api/crypto/handshake']
}));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(express.json({ limit: '10mb' }));
// 2) Crypto endpoints + optional test route
registerCryptoRoutes(app, hybridCrypto, {
pathPrefix: '/api/crypto',
testRoute: true
});
// 3) Encrypt responses when client sends X-Encrypted: true
app.use(encryptResponse(hybridCrypto));
app.get('/health', (req, res) => {
res.json({ status: 'ok', encryption: true });
});
app.post('/api/secure-action', (req, res) => {
// req.body is decrypted when request was encrypted
res.json({ received: req.body });
});
app.listen(3000);
Client usage
Node (or any environment with fetch):
- Use
HybridCryptoClient: callhandshake(baseUrl)once, then useencryptPayload(data)for the body andgetEncryptedRequestHeaders()for headers. For encrypted responses, parse JSON and calldecryptPayload(body)whenbody.encrypted === true.
const { HybridCryptoClient } = require('hybrid-crypto-express');
const client = new HybridCryptoClient('my-service-id');
async function run() {
await client.handshake('http://localhost:3000');
const payload = { userId: 1, action: 'submit' };
const encrypted = client.encryptPayload(payload);
const res = await fetch('http://localhost:3000/api/secure-action', {
method: 'POST',
headers: client.getEncryptedRequestHeaders(),
body: JSON.stringify(encrypted)
});
const body = await res.json();
if (body.encrypted) {
const decrypted = client.decryptPayload(body);
console.log(decrypted);
} else {
console.log(body);
}
}
run();
Browser / SPA:
Use the same flow with the Web Crypto API: fetch public key, generate AES key, encrypt it with the server?s RSA public key (e.g. crypto.subtle), send handshake with X-Client-ID, then send requests with { ciphertext, iv, authTag } and headers X-Encrypted: true, X-Client-ID. This package?s client is Node-oriented (uses crypto); for browsers you typically reimplement the same protocol with crypto.subtle and the same endpoints/headers.
API reference
Main export
const {
HybridCrypto,
DEFAULT_SESSION_EXPIRY_MS,
HybridCryptoClient,
getPublicKey,
decryptRequest,
encryptResponse,
registerCryptoRoutes
} = require('hybrid-crypto-express');
Server
HybridCrypto
Server-side state: RSA key pair + per-client AES session keys.
new HybridCrypto(options?)options.sessionExpiryMs? TTL for each client?s AES key in ms (default:5 * 60 * 1000).options.rsaModulusLength? RSA modulus size in bits (default:2048).
getPublicKey()
Returns{ publicKey, algorithm, hash, keySize, timestamp }(PEM string and metadata).decryptAESKey(encryptedAESKeyBase64)
Decrypts the client?s AES key (base64). Returns 32-byteBuffer. Throws if invalid.storeSessionKey(clientId, aesKey)
Stores the AES key forclientIdwith the configured session expiry.getSessionKey(clientId)
Returns the stored AES key forclientId. Throws if missing or expired.decryptData(ciphertextBase64, ivBase64, authTagBase64, clientId)
Async. Decrypts one message. Returns{ success, data?, raw?, error?, timestamp }.encryptData(data, clientId)
Async. Encryptsdata(JSON-serialized) for that client. Returns{ success, ciphertext?, iv?, authTag?, algorithm?, error?, timestamp }.processHandshake(handshakeData, clientId)
Async. ExpectshandshakeData.encryptedAESKey. Decrypts it, stores session, returns{ success, message?, clientIV?, sessionExpiry?, error?, timestamp }.
decryptRequest(hybridCrypto, options?)
Express middleware. Runs before express.json().
- Reads raw body when
X-Encrypted: trueandX-Client-IDare set. - Expects JSON body
{ ciphertext, iv, authTag }. - On success: sets
req.bodyto the decrypted object, andreq.encryptionMetadata(e.g.type,clientId,algorithm). options.skipPaths? array of paths that skip decryption (default includes/,/health,/api/crypto/public-key,/api/crypto/handshake).
encryptResponse(hybridCrypto)
Express middleware. Wraps res.json.
- When request has
X-Encrypted: trueandX-Client-ID, encrypts the JSON body with the client?s session key and sends{ success, encrypted, algorithm, ciphertext, iv, authTag, timestamp, metadata }. - Does not encrypt error responses (e.g.
res.statusCode >= 400). - Sets headers
X-Encrypted,X-Encryption-Algorithm,X-Client-IDon encrypted responses.
registerCryptoRoutes(app, hybridCrypto, options?)
Adds routes to app:
GET {pathPrefix}/public-key? returnshybridCrypto.getPublicKey().POST {pathPrefix}/handshake? body must containencryptedAESKey;X-Client-IDoptional (defaults to generated id). Responds with handshake result andX-Session-Expiry,X-Client-ID.options.pathPrefix? default'/api/crypto'.options.testRoute? if true, addsPOST {pathPrefix}/testthat echoes back a small JSON object (useful to verify encryption).
Note: Handshake route needs a body parser; so mount decryptRequest first, then express.json(), then registerCryptoRoutes so handshake body is parsed as JSON.
Client
HybridCryptoClient
new HybridCryptoClient(clientId?)clientIddefaults toclient_<timestamp>.handshake(baseUrl, fetchOptions?)
FetchesGET {baseUrl}/api/crypto/public-key, thenPOST {baseUrl}/api/crypto/handshakewith encrypted AES key. Stores session on success. Returns the handshake JSON.encryptPayload(data)
Returns{ ciphertext, iv, authTag }(base64). Throws if no session (handshake first).decryptPayload(encrypted)
Expects{ ciphertext, iv, authTag }. Returns parsed JSON. Throws if no session or decryption fails.getEncryptedRequestHeaders()
Returns{ 'Content-Type': 'application/json', 'X-Client-ID': this.clientId, 'X-Encrypted': 'true' }.
getPublicKey(baseUrl, fetchOptions?)
Standalone helper. Fetches GET {baseUrl}/api/crypto/public-key and returns the JSON (e.g. for custom client flows).
Constant
DEFAULT_SESSION_EXPIRY_MS? default session TTL in ms (5 minutes).
Request / response format
Encrypted request body (client ? server):
{
"ciphertext": "<base64 AES-GCM ciphertext of JSON payload>",
"iv": "<base64 12-byte IV>",
"authTag": "<base64 16-byte auth tag>"
}
Encrypted response body (server ? client):
{
"success": true,
"encrypted": true,
"algorithm": "AES-GCM-256",
"ciphertext": "<base64>",
"iv": "<base64>",
"authTag": "<base64>",
"timestamp": "<ISO8601>",
"metadata": { "clientId": "...", "originalDataType": "object" }
}
Handshake request body:
{
"encryptedAESKey": "<base64 RSA-OAEP encrypted 32-byte AES key>"
}
HTTP headers
| Header (request) | Meaning |
|---|---|
X-Client-ID |
Stable client/session identifier; required for encryption. |
X-Encrypted |
true = body is { ciphertext, iv, authTag } and client wants encrypted response. |
| Header (response) | Meaning |
|---|---|
X-Encrypted |
true when body is the encrypted envelope. |
X-Encryption-Algorithm |
e.g. RSA-AES-GCM. |
X-Session-Expiry |
Session TTL in ms (handshake response). |
X-Client-ID |
Echo of client id (handshake / encrypted response). |
Options & configuration
- Session expiry: Set
sessionExpiryMsinHybridCryptoconstructor. Shorter = more handshakes, better forward secrecy; longer = fewer round-trips. TTL is fixed from handshake (not sliding). - RSA size:
rsaModulusLength(e.g. 2048 or 3072) inHybridCryptoconstructor. - Shared RSA PEMs: Pass
privateKey/publicKeyso all pods share the same keypair. - Session store (required): Pass
sessionStoreimplementing asyncget/set/delete? useRedisSessionStore. Constructor throws if omitted (no in-memory fallback). - Paths: Use
skipPathsindecryptRequestso health checks, static assets, and the crypto routes themselves are never treated as encrypted. - Route prefix: Use
pathPrefixinregisterCryptoRoutes(e.g.'/api/crypto') so paths areGET /api/crypto/public-key,POST /api/crypto/handshake, etc. OptionalPOST .../invalidateends a session before TTL.
Redis session store (multi-pod)
For Kubernetes / multiple replicas, AES sessions must live in Redis ? not process memory.
const { HybridCrypto, RedisSessionStore } = require('hybrid-crypto-express');
const sessionStore = new RedisSessionStore(redisClient, {
serviceName: 'user-service', // keys: hybrid:aes:user-service:<clientId>
// allowReadReplica: true, readClient: redisReadClient, // opt-in only
});
const hybridCrypto = new HybridCrypto({
sessionExpiryMs: 5 * 60 * 1000,
sessionStore,
privateKey: process.env.HYBRID_CRYPTO_PRIVATE_KEY, // optional shared PEM
publicKey: process.env.HYBRID_CRYPTO_PUBLIC_KEY,
});
Behaviour
| Topic | Behavior |
|---|---|
| Expiry | Redis TTL only (no app-side expiresAt) ? avoids clock skew across pods |
| Concurrent handshake | Last-write-wins for the same clientId |
| Redis down on handshake | Returns { success: false, error: 'Session storage unavailable' } ? never a silent memory session |
| Redis down on decrypt | get() ? null + redis_unreachable warn (distinct from session_miss) |
| Stale AES key (GCM auth fail) | Distinct error: Invalid session key ? possible stale session (stale_session_key) |
clientId |
Must match [A-Za-z0-9_-]{1,128} |
| Connection ownership | Pass an already-connected client; the store never opens/closes Redis |
Security: Raw AES-256 session keys are stored in Redis. Production must use Redis AUTH + TLS in transit (and preferably encryption at rest). Rate-limit /api/crypto/handshake per IP to limit keyspace growth.
RSA rotation note: Existing AES sessions in Redis remain valid for decrypt/encrypt until TTL even if RSA keys rotate ? AES and RSA are independent after handshake.
Host apps should close their Redis client on graceful shutdown (the store does not own the connection).
Security considerations
- RSA key: Prefer mounting shared PEMs (
privateKey/publicKey) across pods; otherwise use your host Redis RSA sync. Consider KMS for production rotation. - Session keys: Always stored via
RedisSessionStore(or your own Redis-backed store). See above for Redis AUTH/TLS requirements. - TLS: Use HTTPS in production so the encrypted payloads are not the only protection.
- Client ID: Stable per browser tab/session; sanitize enforced by
RedisSessionStore. - No logging: Do not log raw request/response bodies, AES keys, or private keys.
- Observability: Listen for events (
handshake_success,handshake_failure,session_miss,session_corrupted,decrypt_failure) viaEventEmitteroronEventcallback.
CORS
If your API is called from a browser, allow and expose these so encryption works:
- Allow headers:
X-Client-ID,X-Encrypted,Content-Type, etc. - Expose headers:
X-Encrypted,X-Encryption-Algorithm,X-Session-Expiry,X-Client-ID.
Subpath imports
You can require server, client, or middleware only:
const { HybridCrypto, DEFAULT_SESSION_EXPIRY_MS } = require('hybrid-crypto-express/server');
const { HybridCryptoClient, getPublicKey } = require('hybrid-crypto-express/client');
const { decryptRequest, encryptResponse, registerCryptoRoutes } = require('hybrid-crypto-express/middleware');
License
MIT