Application-level encryption with RSA + AES-256-GCM, step by step.
February 20, 2025
Your API talks over HTTPS. That's good — but what if you need an extra layer? Sensitive payloads, compliance requirements, or zero-trust designs often call for application-level encryption: the client and server encrypt and decrypt payloads themselves, on top of TLS.
This article walks through a hybrid encryption strategy we use in production: RSA for key exchange and AES-256-GCM for bulk data. You'll see the full flow from token creation to request encryption, backend decryption, response encryption, and client-side decryption — with code and concrete implementation references.
HTTPS encrypts the pipe. It does not encrypt the payload in a way only your server and client can read. If a log, proxy, or breach exposes HTTP bodies, sensitive data is visible.
Application-level encryption gives you:
The trade-off is complexity: key exchange, session handling, and consistent use across all API calls. The architecture below is one way to manage that.
We use a hybrid approach:
In short: the client generates a random AES key, the server protects it with RSA and stores the private key in the session. From then on, AES does the heavy lifting for every request and response.
The strategy has five clear phases:
We'll go through each phase and the corresponding code.
Before any encrypted API call, the client and server set up a secure session: the client sends an AES key (used to encrypt the first response), and the server creates an RSA key pair and returns the public key inside an encrypted payload.
The frontend generates a random 32-byte AES key and sends it to the backend (over HTTPS). This key is used only to decrypt the initial response that contains the RSA public key.
const aesKey = crypto.randomBytes(32);The backend:
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
});const encryptData = {
token: token,
publicKey: publicKey
};const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', aesKey, iv);
const encrypted = Buffer.concat([cipher.update(JSON.stringify(encryptData), 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();5. Returns encryptedData, iv, and authTag (e.g. base64-encoded).
Implementation reference: src/services/v1/session.service.ts → createSession(); src/services/v1/token.service.ts → encryptData().
When the frontend sends sensitive data, it encrypts the payload with a new random AES key and IV, then encrypts that AES key with the backend's public key. So the backend can decrypt the key with its private key, then decrypt the body.
const aesKey = crypto.randomBytes(32);
const iv = crypto.randomBytes(12);const cipher = crypto.createCipheriv("aes-256-gcm", aesKey, iv);
let encrypted = cipher.update(JSON.stringify(data), "utf8", "base64");
encrypted += cipher.final("base64");
const authTag = cipher.getAuthTag();// Encrypt AES key using BE public key
const encryptedKey = crypto.publicEncrypt(
bePublicKey,
aesKey
);The request body typically includes:
Note: Use a new AES key and IV per request (or per logical operation) to avoid nonce reuse and limit blast radius if a key is ever compromised.
When the backend receives an encrypted request, it uses the session's private key to decrypt the AES key, then uses that key to decrypt the body.
const session = await sessionService.getSessionWithValidation(sessionId);
const { privateKey } = session;const encryptedAesKeyBuffer = Buffer.from(encryptedAesKey, 'base64');
const aesKeyBuffer = privateDecrypt(
{
key: privateKey,
padding: constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
encryptedAesKeyBuffer
);const iv = Buffer.from(payload.iv, 'base64');
const authTag = Buffer.from(payload.authTag, 'base64');
const decipher = createDecipheriv('aes-256-gcm', aesKeyBuffer, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(Buffer.from(payload.encryptedData, 'base64')),
decipher.final()
]);
const decryptedData = JSON.parse(decrypted.toString('utf8'));Implementation reference: src/services/v1/security.service.ts → decryptPayload(); src/services/v1/token.service.ts → decryptAesKey(), decryptData().
Before sending a response back, the backend encrypts it with the same AES key the client used for that request (or the one associated with the session, depending on your design). The backend gets the AES key from the request (encrypted with its public key) or from the session.
Same pattern as decryption: load session, decrypt encryptedAesKey with the private key.
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', aesKeyBuffer, iv);
const encrypted = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();Return encryptedData, iv, and authTag (e.g. base64).
Implementation reference: src/services/v1/security.service.ts → encryptPayload(); src/services/v1/token.service.ts → decryptAesKey(), encryptData().
The frontend receives encryptedData, iv, and authTag. It uses the AES key it generated (and stored) for that session or request to decrypt and parse the response.
const encryptedDataBuffer = Buffer.from(encryptedData, 'base64');
const ivBuffer = Buffer.from(iv, 'base64');
const authTagBuffer = Buffer.from(authTag, 'base64');
const decipher = crypto.createDecipheriv("aes-256-gcm", aesKey, ivBuffer);
decipher.setAuthTag(authTagBuffer);
let decrypted = decipher.update(encryptedDataBuffer, null, 'utf8');
decrypted += decipher.final('utf8');
const decryptedData = JSON.parse(decrypted);function decryptApiResponse(encryptedData, iv, authTag, aesKey) {
// Decode base64 encoded values
const encryptedDataBuffer = Buffer.from(encryptedData, 'base64');
const ivBuffer = Buffer.from(iv, 'base64');
const authTagBuffer = Buffer.from(authTag, 'base64');
// Create decipher
const decipher = crypto.createDecipheriv("aes-256-gcm", aesKey, ivBuffer);
decipher.setAuthTag(authTagBuffer);
// Decrypt data
let decrypted = decipher.update(encryptedDataBuffer, null, 'utf8');
decrypted += decipher.final('utf8');
// Parse and return decrypted data
return JSON.parse(decrypted);
}Important: The frontend must store the AES key securely (e.g. in memory or a secure storage appropriate for your platform). Losing it means responses cannot be decrypted. Handle decryption errors (wrong key, tampering, corruption) and never expose raw crypto errors to the UI.
┌─────────────────────────────────────────────────────────────────┐
│ Initial Token Creation │
├─────────────────────────────────────────────────────────────────┤
│ FE: Generate AES Key → Send to BE │
│ BE: Generate RSA Key Pair → Store in Redis → Encrypt Response │
│ BE: Return {encryptedData, iv, authTag} │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Frontend Payload Encryption │
├─────────────────────────────────────────────────────────────────┤
│ FE: Generate AES Key + IV │
│ FE: Encrypt Data (AES-256-GCM) │
│ FE: Encrypt AES Key (RSA Public Key) │
│ FE: Send {encryptedAesKey, encryptedData, iv, authTag} │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Backend Payload Decryption │
├─────────────────────────────────────────────────────────────────┤
│ BE: Receive {encryptedAesKey, encryptedData, iv, authTag} │
│ BE: Retrieve Session from Redis │
│ BE: Decrypt AES Key (RSA Private Key) │
│ BE: Decrypt Data (AES-256-GCM) │
│ BE: Return Decrypted Data │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Backend Response Encryption │
├─────────────────────────────────────────────────────────────────┤
│ BE: Receive {data, encryptedAesKey} │
│ BE: Retrieve Session from Redis │
│ BE: Decrypt AES Key (RSA Private Key) │
│ BE: Generate New IV │
│ BE: Encrypt Data (AES-256-GCM) │
│ BE: Return {encryptedData, iv, authTag} │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Frontend API Response Decryption │
├─────────────────────────────────────────────────────────────────┤
│ FE: Receive {encryptedData, iv, authTag} from API │
│ FE: Retrieve Stored AES Key │
│ FE: Decode Base64 Values │
│ FE: Decrypt Data (AES-256-GCM) │
│ FE: Parse and Use Decrypted Data │
└─────────────────────────────────────────────────────────────────┘Application-level encryption adds a strong layer on top of HTTPS: you control keys and algorithms, and only your client and server can read the payloads. This hybrid design — RSA for key exchange, AES-256-GCM for data, sessions in Redis — gives you a clear, implementable pattern for request/response encryption.
Start with the initial token and session setup, then plug in the same encrypt/decrypt steps on both sides. Once the flow is in place, you can reuse it across endpoints and clients.