AT
WorkSkillsJourneyContactBlog
Hire me
AT
WorkSkillsJourneyContactBlog
Hire me
← Back to blog

What If HTTPS Isn't Enough?

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.


Why Encrypt at the Application Layer?

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:

  • ·Confidentiality — Only the intended client and server can read the data.
  • ·Integrity — Tampering is detected via authentication tags (e.g. GCM).
  • ·Control — You decide what is encrypted and where keys live.

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.


RSA + AES and Session-Based Keys

We use a hybrid approach:

  • ·RSA (2048-bit) — Used only to protect the exchange of symmetric keys. The backend holds a key pair per session; the public key is sent to the client (inside an encrypted envelope).
  • ·AES-256-GCM — Used for all bulk encryption and decryption of request/response bodies. Fast and provides both confidentiality and authenticity.
  • ·Sessions in Redis — Each session stores the RSA key pair and metadata. The private key never leaves the backend.

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 Five Phases of the Flow

The strategy has five clear phases:

  • ·Initial token creation — Establish session and exchange public key.
  • ·Frontend payload encryption — Encrypt request body with AES; encrypt AES key with RSA.
  • ·Backend payload decryption — Recover AES key from session, decrypt body.
  • ·Backend response encryption — Encrypt response with the same AES key.
  • ·Frontend response decryption — Decrypt response using the stored AES key.

We'll go through each phase and the corresponding code.


Phase 1: Initial Token Creation

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.

What the frontend does

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.

javascript
const aesKey = crypto.randomBytes(32);

What the backend does

The backend:

  • ·Generates an RSA key pair (2048-bit, PEM):
typescript
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
    modulusLength: 2048,
    publicKeyEncoding: {
        type: 'spki',
        format: 'pem'
    },
    privateKeyEncoding: {
        type: 'pkcs8',
        format: 'pem'
    }
});
  • ·Creates a session — Generates a session ID (e.g. UUID), builds a JWT with sessionId, deviceId, ipAddress, and stores in Redis: sessionId, privateKey, publicKey, deviceId, ipAddress.
  • ·Encrypts the first response — Builds an object with token and publicKey, then encrypts it with the AES key sent by the frontend:
typescript
const encryptData = {
    token: token,
    publicKey: publicKey
};
  • ·Uses AES-256-GCM — Random IV, encrypt, get auth tag:
typescript
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().


Phase 2: Frontend Payload Encryption

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.

Step 1: Generate AES key and IV

javascript
const aesKey = crypto.randomBytes(32);
const iv = crypto.randomBytes(12);

Step 2: Encrypt payload with AES-256-GCM

javascript
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();

Step 3: Encrypt the AES key with the backend public key

javascript
// Encrypt AES key using BE public key
const encryptedKey = crypto.publicEncrypt(
  bePublicKey,
  aesKey
);

Step 4: Send to backend

The request body typically includes:

  • ·encryptedAesKey — AES key encrypted with RSA (base64)
  • ·encryptedData — AES-256-GCM ciphertext (base64)
  • ·iv — Initialization vector (base64)
  • ·authTag — GCM authentication tag (base64)

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.


Phase 3: Backend Payload Decryption

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.

Step 1: Load session from Redis

typescript
const session = await sessionService.getSessionWithValidation(sessionId);
const { privateKey } = session;

Step 2: Decrypt the AES key (RSA-OAEP)

typescript
const encryptedAesKeyBuffer = Buffer.from(encryptedAesKey, 'base64');
const aesKeyBuffer = privateDecrypt(
    {
        key: privateKey,
        padding: constants.RSA_PKCS1_OAEP_PADDING,
        oaepHash: 'sha256'
    },
    encryptedAesKeyBuffer
);

Step 3: Decrypt the body (AES-256-GCM)

typescript
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().


Phase 4: Backend Response Encryption

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.

Retrieve session and decrypt AES key

Same pattern as decryption: load session, decrypt encryptedAesKey with the private key.

Encrypt response with AES-256-GCM

typescript
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().


Phase 5: Frontend API Response Decryption

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.

Decode and decrypt

javascript
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);

Complete frontend decryption helper

javascript
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.


Security Choices in Practice

AES-256-GCM

  • ·Algorithm: AES with 256-bit key, Galois/Counter Mode.
  • ·Why: Confidentiality and authenticity in one; the auth tag detects tampering and decryption with the wrong key.

RSA-OAEP

  • ·Key size: 2048-bit.
  • ·Padding: OAEP with SHA-256.
  • ·Role: Safe key encapsulation for the AES key; we don't encrypt large payloads with RSA.

Session management

  • ·Storage: Redis with TTL.
  • ·Contents: Private key, public key, session metadata.
  • ·Rule: Private keys stay on the backend and are never sent to the client.

Data Flow at a Glance

text
┌─────────────────────────────────────────────────────────────────┐
│                    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                                │
└─────────────────────────────────────────────────────────────────┘

Lessons Learned

  • ·AES key lifecycle — Generate a new random AES key (and IV) per request or per session depending on your threat model. Never reuse an IV with the same key.
  • ·IV size for GCM — Use a 12-byte (96-bit) IV for AES-GCM; it's the standard and avoids compatibility issues.
  • ·Key exchange — Keep RSA for key encapsulation only; use AES for all payloads.
  • ·Session storage — Redis (or equivalent) with TTL keeps private keys off the client and allows scaling and expiry.
  • ·Integrity — Always set and verify the GCM auth tag; don't skip it for "performance."
  • ·Encoding — Base64 for encrypted data, IVs, and auth tags keeps them safe in JSON and HTTP bodies.
  • ·Frontend key storage — Design where and how long the client keeps the AES key; balance security and UX (e.g. in-memory vs. longer-lived storage).
  • ·Error handling — Catch decryption failures (e.g. from final()) and return generic errors to the client; avoid leaking crypto details.

Conclusion

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.