Plug-and-play End-to-End Encryption middleware for Express.js and NestJS using hybrid AES-CBC + RSA encryption with secure key exchange
npm install e2ee-adapter> "60% of this code was written by an AI (Cursor), but I am not a passive coder — every line has been verified, fixed, and tested by me (a human)."
A plug-and-play TypeScript package providing End-to-End Encryption (E2EE) middleware for Express.js and NestJS applications using hybrid encryption (AES-CBC + RSA).
- Hybrid Encryption: AES-CBC for data encryption + RSA for key exchange
- Express.js Middleware: Easy integration with Express applications
- NestJS Interceptor: Seamless integration with NestJS applications
- Client SDK: TypeScript client for making encrypted requests
- Header-based Flow: Secure transmission using custom headers
- Automatic Key Management: Server generates and manages RSA key pairs
- Full Bidirectional Encryption: Request and response encryption support
- GET Request Encryption: Encrypt responses for GET requests (even with empty request bodies)
- Multi-Domain Support: Multiple encryption keys for different domains/tenants
- Custom Header Configuration: Configurable header names for flexibility
- Path & Method Exclusion: Exclude specific paths and HTTP methods from encryption
- Enforcement Modes: Strict or flexible encryption enforcement
- Empty Request Body Support: Encrypt responses for requests without data
- Callback Hooks: Error handling, decryption, and encryption callbacks
- Utility Functions: Key generation, encryption, and decryption utilities
- Automatic Response Encryption: Transparent response encryption using res.send()
- Plug-and-Play Integration: Zero configuration required for basic setup
- AES-256-CBC: Symmetric encryption for data
- RSA-2048-OAEP: Asymmetric encryption for key exchange
- Random IV Generation: Unique initialization vectors for each request
- Secure Key Exchange: RSA encryption for AES key transmission
``bash`
npm install e2ee-adapter express
`bash`
npm install e2ee-adapter @nestjs/common rxjs
The middleware implements a secure hybrid encryption flow that supports both request encryption and response encryption for all HTTP methods, including GET requests with empty bodies:
``
+-------------------------+ +-------------------------+
| CLIENT | | SERVER |
+-------------------------+ +-------------------------+
| ▲
| 1. Fetch server's public key from /e2ee.json |
| → key: RSA public PEM |
| → key_id: version info |
| |
| 2. Generate AES key (32 bytes) and IV (16 bytes) |
| |
| 3. Encrypt request payload using: |
| AES-CBC(payload, AES_key, IV) |
| |
| 4. Encrypt AES key using server's RSA public key |
| RSA_encrypt(AES_key, server_pubkey) |
| |
| 5. Send HTTPS request: |
| ---------------------------------------------- |
| Headers: |
| x-custom-key: RSA_encrypted_AES_key (base64) |
| x-custom-iv: IV (base64) |
| x-key-id: key_id |
| Content-Type: application/json |
| Body: |
| Encrypted AES payload (base64) |
| ---------------------------------------------- |
| |
| ▼
| +------------------------------------------+
| | 6. Decrypt x-custom-key using RSA private|
| | AES_key = RSA_decrypt(x-custom-key) |
| +------------------------------------------+
| ▼
| +------------------------------------------+
| | 7. Decrypt payload using AES-CBC |
| | plaintext = AES_decrypt(body, AES_key,|
| | IV)|
| +------------------------------------------+
| ▼
| +------------------------------------------+
| | 8. Process request, prepare response JSON |
| +------------------------------------------+
| ▼
| +------------------------------------------+
| | 9. Encrypt response using AES-CBC |
| | response_encrypted = AES_encrypt( |
| | JSON, AES_key, IV) |
| +------------------------------------------+
| ▼
| +------------------------------------------+
| | 10. Send encrypted response |
| | Body: response_encrypted (base64) |
| +------------------------------------------+
| ▲
| 11. Decrypt response using AES_key and IV |
| plaintext_response = AES_decrypt(body, key, IV) |
| |
+-------------------------+ +-------------------------+
The E2EE adapter is designed to be plug-and-play - you can get started with minimal configuration:
`typescript
// Express.js - Just add the middleware
import { createE2EEMiddleware, generateMultipleKeyPairs } from 'e2ee-adapter';
const keys = await generateMultipleKeyPairs(['domain1']);
const e2eeMiddleware = createE2EEMiddleware({ config: { keys } });
app.use(e2eeMiddleware); // That's it!
// NestJS - Just add the interceptor
import { E2EEInterceptor } from 'e2ee-adapter';
@UseInterceptors(E2EEInterceptor)
@Controller('api')
export class UsersController {
// Your endpoints are now encrypted!
}
`
The E2EE middleware and interceptor should be the first one to be used in your application stack. This ensures that:
- Request decryption happens before any other middleware processes the request
- Response encryption happens after your application logic but before any response middleware
- Security headers are properly set and maintained throughout the request lifecycle
- No data leakage occurs through other middleware that might log or process request/response data
For Express.js: Place the E2EE middleware as early as possible in your middleware stack, typically right after basic middleware like express.json() and express.urlencoded().
For NestJS: Apply the E2EE interceptor globally or at the controller level to ensure it runs before other interceptors and guards.
The package provides specific module paths for middleware, client, and interceptor:
`typescript
// Main exports (everything)
import { createE2EEMiddleware, E2EEClient, E2EEInterceptor, generateMultipleKeyPairs } from 'e2ee-adapter';
// Specific modules (optional)
import { createE2EEMiddleware } from 'e2ee-adapter/middleware';
import { E2EEClient } from 'e2ee-adapter/client';
import { E2EEInterceptor } from 'e2ee-adapter/interceptor';
`
`typescript
import express from 'express';
import { generateMultipleKeyPairs } from 'e2ee-adapter';
import { createE2EEMiddleware } from 'e2ee-adapter/middleware';
const app = express();
// Generate multiple RSA key pairs
const keys = await generateMultipleKeyPairs(['domain1', 'domain2', 'domain3']);
// Create E2EE middleware
const e2eeMiddleware = createE2EEMiddleware({
config: {
keys,
enableRequestDecryption: true,
enableResponseEncryption: true,
excludePaths: ['/health', '/keys', '/e2ee.json'],
excludeMethods: ['GET', 'HEAD', 'OPTIONS'],
enforced: false // Allow both encrypted and non-encrypted requests
},
onError: (error, req, res) => {
console.error('E2EE Error:', error.message);
},
onDecrypt: (decryptedData, req) => {
console.log('Request decrypted successfully');
},
onEncrypt: (encryptedData, res) => {
console.log('Response encrypted successfully');
}
});
// Apply middleware (IMPORTANT: Place this early in your middleware stack)
app.use(e2eeMiddleware);
// Add server configuration endpoint
app.get('/e2ee.json', (req, res) => {
res.json({
keys: {
domain1: keys.domain1.publicKey,
domain2: keys.domain2.publicKey,
domain3: keys.domain3.publicKey
},
keySize: 2048
});
});
// Protected endpoints
app.post('/api/users', (req, res) => {
// req.body contains decrypted data
const user = { id: Date.now(), ...req.body };
// The middleware will automatically encrypt the response if encryption context is available
res.send({ success: true, user });
});
// GET endpoint with encrypted response
app.get('/api/users/:id', (req, res) => {
const user = { id: req.params.id, name: 'John Doe' };
res.send({ success: true, user }); // Will be automatically encrypted
});
`
Important: Configure bodyParser for plain text requests
Before setting up the E2EE interceptor, you need to configure your NestJS application to handle plain text requests:
`typescript
import * as bodyParser from 'body-parser';
// Add this to your main.ts or app.module.ts
app.use(bodyParser.text({ type: 'text/plain' }));
`
E2EE Interceptor Setup:
`typescript
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { E2EEInterceptor } from 'e2ee-adapter/interceptor';
@Injectable()
export class E2EEInterceptor extends E2EEInterceptor {
constructor() {
super({
config: {
keys: {
domain1: {
privateKey: process.env.E2EE_DOMAIN1_PRIVATE_KEY,
publicKey: process.env.E2EE_DOMAIN1_PUBLIC_KEY
},
domain2: {
privateKey: process.env.E2EE_DOMAIN2_PRIVATE_KEY,
publicKey: process.env.E2EE_DOMAIN2_PUBLIC_KEY
}
},
enableRequestDecryption: true,
enableResponseEncryption: true,
enforced: true // Strictly require encryption for all requests
}
});
}
}
// Apply to controller or globally (IMPORTANT: Apply early in the interceptor chain)
@UseInterceptors(E2EEInterceptor)
@Controller('api')
export class UsersController {
@Post('users')
createUser(@Body() userData: any) {
// userData is automatically decrypted
return { success: true, user: userData };
}
}
`
`typescript
import { E2EEClient } from 'e2ee-adapter/client';
// Create client with multiple server keys
const client = new E2EEClient({
serverKeys: {
domain1: '-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----',
domain2: '-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----'
}
});
// Make encrypted requests
const response = await client.request({
url: 'https://api.example.com/api/users',
method: 'POST',
data: {
name: 'John Doe',
email: 'john@example.com'
},
keyId: 'domain1' // Required: specify which key to use
});
console.log(response.data); // Automatically decrypted response
`
`typescript
interface KeyPair {
/* RSA public key in PEM format /
publicKey: string;
/* RSA private key in PEM format /
privateKey: string;
}
interface KeyStore {
/* Mapping of keyId to key pair /
[keyId: string]: KeyPair;
}
interface E2EEConfig {
/* Multiple keys store for multi-domain support /
keys: KeyStore;
/* Custom key header name (default: x-custom-key) /
customKeyHeader?: string;
/* Custom IV header name (default: x-custom-iv) /
customIVHeader?: string;
/* Key ID header name (default: x-key-id) /
keyIdHeader?: string;
/* Enable request decryption (default: true) /
enableRequestDecryption?: boolean;
/* Enable response encryption (default: true) /
enableResponseEncryption?: boolean;
/* Paths to exclude from encryption (default: ['/health', '/keys', '/e2ee.json']) /
excludePaths?: string[];
/* HTTP methods to exclude from encryption (default: ['GET', 'HEAD', 'OPTIONS']) /
excludeMethods?: string[];
/* If true, strictly enforce encryption for all requests. If false, only check for encryption after identifying headers (default: false) /
enforced?: boolean;
/* If true, allow empty request bodies while still enabling encrypted responses (default: false) /
allowEmptyRequestBody?: boolean;
}
interface E2EEMiddlewareOptions {
/* E2EE configuration /
config: E2EEConfig;
/* Error callback for handling E2EE errors /
onError?: (error: Error, req: any, res: any) => void;
/* Callback triggered when request is successfully decrypted /
onDecrypt?: (decryptedData: DecryptedData, req: any) => void;
/* Callback triggered when response is successfully encrypted /
onEncrypt?: (encryptedData: EncryptedData, res: any) => void;
}
`
`typescript`
interface E2EEClientConfig {
/* Multiple server keys for multi-domain support /
serverKeys: { [keyId: string]: string };
/* Key ID for versioning /
keyId?: string;
}
The middleware automatically encrypts responses when encryption context is available. Simply use the standard res.send() method:
`typescript
// Encrypted response (when E2EE context exists)
app.post('/api/data', (req, res) => {
const data = { message: 'Hello World' };
res.send(data); // Automatically encrypted
});
// Encrypted response for GET requests
app.get('/api/data', (req, res) => {
const data = { message: 'Hello World' };
res.send(data); // Automatically encrypted if E2EE context exists
});
// Non-encrypted response (bypasses E2EE)
app.get('/api/public', (req, res) => {
const data = { message: 'Public data' };
res.json(data); // Never encrypted
});
`
See examples/express-server/server.js for a complete working example.
See examples/client-example/client.js for a complete working example.
See examples/vanilla-js-client/ for a browser-based vanilla JavaScript client with interactive UI.
See examples/nestjs-server/ for a complete NestJS application with proper architecture, DTOs, entities, and global E2EE interceptor configuration.
1. Install the package:
`bash`
# For Express.js
npm install e2ee-adapter express
# For NestJS
npm install e2ee-adapter @nestjs/common rxjs
2. Generate multiple key pairs:
`typescript`
import { generateMultipleKeyPairs } from 'e2ee-adapter';
const keys = await generateMultipleKeyPairs(['domain1', 'domain2', 'domain3']);
3. Set up Express middleware:
`typescript`
import { createE2EEMiddleware } from 'e2ee-adapter/middleware';
const e2eeMiddleware = createE2EEMiddleware({
config: {
keys
}
});
// IMPORTANT: Place this early in your middleware stack
app.use(e2eeMiddleware);
4. Create client:
`typescript`
import { E2EEClient } from 'e2ee-adapter/client';
const client = new E2EEClient({
serverKeys: {
domain1: keys.domain1.publicKey,
domain2: keys.domain2.publicKey,
domain3: keys.domain3.publicKey
}
});
5. Make encrypted requests:
`typescript
// POST request with encrypted data
const response = await client.request({
url: 'http://localhost:3000/api/users',
method: 'POST',
data: { name: 'John Doe' },
keyId: 'domain1' // Required: specify which key to use
});
// GET request with encrypted response (no request body needed)
const userResponse = await client.request({
url: 'http://localhost:3000/api/users/123',
method: 'GET',
keyId: 'domain1' // Required: specify which key to use
});
`
The library supports multiple encryption keys for different domains or tenants sharing the same server infrastructure. This is useful for:
- Multi-tenant applications where each tenant has their own encryption keys
- Domain-specific encryption where different domains use different keys
- Key rotation where new keys can be added while old ones remain valid
- Isolation ensuring data encrypted with one key cannot be decrypted with another
- Explicit key selection requiring users to specify which key to use for each request
1. Server Configuration: Configure multiple key pairs with unique keyIds
2. Client Configuration: Store public keys for all domains you need to communicate with
3. Request-Level Key Selection: Specify which keyId to use for each request (required)
4. Automatic Key Resolution: Server automatically selects the correct private key based on the keyId header
`typescript
// Server setup for multiple domains
const keys = await generateMultipleKeyPairs(['tenant1', 'tenant2', 'tenant3']);
const middleware = createE2EEMiddleware({
config: { keys }
});
// Client setup for multiple domains
const client = new E2EEClient({
serverKeys: {
tenant1: keys.tenant1.publicKey,
tenant2: keys.tenant2.publicKey,
tenant3: keys.tenant3.publicKey
}
});
// Use different keys for different requests (keyId is required)
await client.request({ url: '/api/data', method: 'POST', data: data1, keyId: 'tenant1' });
await client.request({ url: '/api/data', method: 'POST', data: data2, keyId: 'tenant2' });
`
- Key Management: Store private keys securely and never expose them
- Key Rotation: Implement key rotation mechanisms for production use
- HTTPS: Always use HTTPS in production to protect against MITM attacks
- Key Size: Use 2048-bit RSA keys minimum for production
- Algorithm: The middleware uses RSA-OAEP with SHA-256 for optimal security
- Key Isolation: Ensure proper key isolation between different domains/tenants
The library supports two enforcement modes to control how encryption is handled:
`typescript
// Non-enforced mode (default) - allows mixed requests
const middleware = createE2EEMiddleware({
config: {
keys,
enforced: false // or omit this line
}
});
// Enforced mode - requires all requests to be encrypted
const middleware = createE2EEMiddleware({
config: {
keys,
enforced: true
}
});
`
- Development/Testing: Use non-enforced mode to test both encrypted and non-encrypted endpoints
- Gradual Migration: Start with non-enforced mode and gradually migrate clients
- Production: Use enforced mode to ensure all traffic is encrypted
- Mixed Environments: Use non-enforced mode when some clients cannot support encryption
The package provides several utility functions for key generation and encryption operations:
`typescript
import {
generateKeyPair,
generateMultipleKeyPairs,
encrypt,
decrypt,
encryptAES,
decryptAES,
decryptAESKey
} from 'e2ee-adapter';
// Generate a single RSA key pair
const keyPair = await generateKeyPair(2048);
// Generate multiple key pairs for different domains
const keys = await generateMultipleKeyPairs(['domain1', 'domain2', 'domain3']);
// Encrypt data using hybrid encryption
const encrypted = await encrypt('sensitive data', publicKey);
// Decrypt data
const decrypted = await decrypt(encryptedData, encryptedKey, iv, privateKey);
// AES-only encryption/decryption
const aesEncrypted = encryptAES('data', aesKey, iv);
const aesDecrypted = decryptAES(encryptedData, aesKey, iv);
// Decrypt AES key from headers (for empty request bodies)
const { aesKey, iv } = await decryptAESKey(encryptedKey, iv, privateKey);
`
One of the key features of this library is the ability to encrypt responses for GET requests, even when they have no request body. This is particularly useful for:
- Secure Data Retrieval: GET requests that return sensitive data
- API Endpoints: Public APIs that need to return encrypted responses
- Stateless Operations: Operations that don't require request data but need secure responses
1. Client sends GET request with encryption headers (AES key encrypted with RSA)
2. No request body needed - the encryption context is established via headers
3. Server processes request and generates response data
4. Response is automatically encrypted using the AES key from headers
5. Client decrypts response using the same AES key
`typescript
// Server endpoint
app.get('/api/users/:id', (req, res) => {
const user = { id: req.params.id, name: 'John Doe', email: 'john@example.com' };
res.send(user); // Automatically encrypted response
});
// Client request
const response = await client.request({
url: 'https://api.example.com/api/users/123',
method: 'GET',
keyId: 'domain1' // No data needed, but encryption headers required
});
console.log(response.data); // Automatically decrypted user data
`
The library supports encrypted responses even for requests with empty bodies (like GET requests or POST requests without data). This is useful when you want to:
- GET requests with encrypted responses: Retrieve data securely without sending any request body
- POST requests without data: Submit forms or trigger actions that don't require request data
- API endpoints that only return data: Endpoints that don't accept input but should return encrypted responses
`typescript`
// Enable empty request body support
const middleware = createE2EEMiddleware({
config: {
keys,
allowEmptyRequestBody: true, // Enable this feature
enableRequestDecryption: true,
enableResponseEncryption: true
}
});
1. Client sends request with encryption headers but no request body
2. Server processes request by decrypting the AES key from headers
3. Server generates response and encrypts it using the decrypted AES key
4. Client receives encrypted response and decrypts it
`typescript
// GET request with encrypted response
app.get('/api/users', (req, res) => {
const users = [{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }];
res.send(users); // Response will be automatically encrypted
});
// POST request without body but with encrypted response
app.post('/api/health-check', (req, res) => {
const status = { status: 'healthy', timestamp: Date.now() };
res.send(status); // Response will be automatically encrypted
});
`
`typescript
// GET request with encrypted response
const response = await client.request({
url: 'https://api.example.com/api/users',
method: 'GET',
keyId: 'domain1' // No data needed, but encryption headers required
});
console.log(response.data); // Automatically decrypted response
// POST request without body but with encrypted response
const healthResponse = await client.request({
url: 'https://api.example.com/api/health-check',
method: 'POST',
keyId: 'domain1' // No data needed, but encryption headers required
});
console.log(healthResponse.data); // Automatically decrypted response
``
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests
5. Submit a pull request
This project is licensed under the MIT License - see the LICENSE file for details.