Engineering 12 min read

How We Built Double-Layer Encryption for Document Redaction

A technical deep-dive into implementing AES-256-GCM application-layer encryption on top of TLS. Why we did it, how it works, and the code behind it.

Why Encrypt Beyond TLS?

Let's address the obvious question: if HTTPS/TLS already encrypts data in transit, why add another layer?

For most applications, TLS is sufficient. But SafeRedact processes sensitive documents—tax returns, medical records, legal filings. Our users are trusting us with their most confidential data. We wanted to provide defense-in-depth that goes beyond "we use HTTPS like everyone else."

What application-layer encryption adds:

  • Protection at edge/CDN: TLS terminates at Cloudflare/Vercel edge. Application encryption means data is still encrypted at that layer.
  • No plaintext logging: Even if server logs captured request bodies, they'd be encrypted blobs.
  • Credibility signal: For enterprise customers and security questionnaires, "application-layer AES-256" is a differentiator.
  • GCM authentication: Guarantees response integrity—detects any tampering.

Is it strictly necessary? No. Is it a meaningful security improvement for a privacy-focused product? We think so.

The Architecture

Here's the data flow with encryption:

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│    Browser      │     │   SafeRedact    │     │   Claude API    │
│                 │     │     Server      │     │   (Anthropic)   │
└────────┬────────┘     └────────┬────────┘     └────────┬────────┘
         │                       │                       │
    1. Generate AES-256 key      │                       │
         │                       │                       │
    2. Encrypt text with         │                       │
       AES-256-GCM               │                       │
         │                       │                       │
         │──── encrypted blob ──►│                       │
         │      + IV + key       │                       │
         │      (over TLS)       │                       │
         │                       │                       │
         │                  3. Decrypt                   │
         │                       │                       │
         │                       │──── plaintext ───────►│
         │                       │      (over TLS)       │
         │                       │                       │
         │                       │◄─── detections ───────│
         │                       │      (over TLS)       │
         │                       │                       │
         │                  4. Re-encrypt                │
         │                       │                       │
         │◄── encrypted blob ────│                       │
         │      + IV             │                       │
         │      (over TLS)       │                       │
         │                       │                       │
    5. Decrypt response          │                       │
         │                       │                       │
         ▼                       │                       │
    Process detections           │                       │

Key points:

  • The browser generates a fresh 256-bit key for each request
  • The key is sent alongside the encrypted payload (yes, we'll discuss this)
  • The server decrypts, processes, and re-encrypts the response with the same key
  • TLS wraps everything—so it's encryption inside encryption

Client-Side Implementation

We use the Web Crypto API, which is available in all modern browsers and provides hardware-accelerated cryptographic operations.

The encryption module


// SafeEncrypt module - browser-side AES-256-GCM
const SafeEncrypt = {
    
    // Generate a random 256-bit key
    async generateKey() {
        return await crypto.subtle.generateKey(
            { name: 'AES-GCM', length: 256 },
            true,  // extractable (we need to send it)
            ['encrypt', 'decrypt']
        );
    },
    
    // Export key to base64 for transmission
    async exportKey(key) {
        const raw = await crypto.subtle.exportKey('raw', key);
        return btoa(String.fromCharCode(...new Uint8Array(raw)));
    },
    
    // Encrypt data with AES-256-GCM
    async encrypt(data, key) {
        // 96-bit IV (NIST recommended for GCM)
        const iv = crypto.getRandomValues(new Uint8Array(12));
        const encoded = new TextEncoder().encode(JSON.stringify(data));
        
        const encrypted = await crypto.subtle.encrypt(
            { name: 'AES-GCM', iv },
            key,
            encoded
        );
        
        return {
            encrypted: btoa(String.fromCharCode(...new Uint8Array(encrypted))),
            iv: btoa(String.fromCharCode(...iv))
        };
    },
    
    // Decrypt response
    async decrypt(encryptedBase64, ivBase64, key) {
        const encrypted = Uint8Array.from(atob(encryptedBase64), c => c.charCodeAt(0));
        const iv = Uint8Array.from(atob(ivBase64), c => c.charCodeAt(0));
        
        const decrypted = await crypto.subtle.decrypt(
            { name: 'AES-GCM', iv },
            key,
            encrypted
        );
        
        return JSON.parse(new TextDecoder().decode(decrypted));
    }
};

Using it in the API call


async function sendEncryptedRequest(textItems) {
    // 1. Generate ephemeral key
    const key = await SafeEncrypt.generateKey();
    const exportedKey = await SafeEncrypt.exportKey(key);
    
    // 2. Encrypt the payload
    const { encrypted, iv } = await SafeEncrypt.encrypt(
        { textItems }, 
        key
    );
    
    // 3. Send encrypted request
    const response = await fetch('/api/detect', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ encrypted, iv, key: exportedKey })
    });
    
    const data = await response.json();
    
    // 4. Decrypt response
    if (data.encrypted) {
        return await SafeEncrypt.decrypt(data.encrypted, data.iv, key);
    }
    
    return data;
}

Server-Side Implementation

On the server (Node.js serverless function), we use the built-in crypto module:


const crypto = require('crypto');

function decryptPayload(encryptedData, iv, key) {
    const decipher = crypto.createDecipheriv(
        'aes-256-gcm',
        Buffer.from(key, 'base64'),
        Buffer.from(iv, 'base64')
    );
    
    // GCM auth tag is last 16 bytes
    const encBuffer = Buffer.from(encryptedData, 'base64');
    const authTag = encBuffer.slice(-16);
    const ciphertext = encBuffer.slice(0, -16);
    
    decipher.setAuthTag(authTag);
    
    let decrypted = decipher.update(ciphertext);
    decrypted = Buffer.concat([decrypted, decipher.final()]);
    
    return JSON.parse(decrypted.toString('utf8'));
}

function encryptResponse(data, key) {
    const iv = crypto.randomBytes(12);
    const cipher = crypto.createCipheriv(
        'aes-256-gcm',
        Buffer.from(key, 'base64'),
        iv
    );
    
    let encrypted = cipher.update(JSON.stringify(data), 'utf8');
    encrypted = Buffer.concat([encrypted, cipher.final()]);
    
    // Append auth tag
    const authTag = cipher.getAuthTag();
    const combined = Buffer.concat([encrypted, authTag]);
    
    return {
        encrypted: combined.toString('base64'),
        iv: iv.toString('base64')
    };
}

One gotcha: Web Crypto API includes the GCM auth tag in the ciphertext output, but Node's crypto module returns it separately. We append it to the ciphertext for consistency.

Key Management Decisions

The elephant in the room: we send the encryption key alongside the encrypted payload. Isn't that defeating the purpose?

Why we chose ephemeral symmetric keys

Yes, an attacker who intercepts the TLS-encrypted request gets both the key and the ciphertext. But that attacker already broke TLS—at which point they could read plaintext anyway.

The threat model isn't "TLS is broken." It's:

  • • Edge/CDN layer logging plaintext requests
  • • Server-side request logging capturing sensitive data
  • • Memory dumps or debugging tools exposing request bodies

Alternative approaches we considered:

Public-key cryptography

Server has a keypair, browser encrypts with public key. More secure key exchange, but adds complexity and latency (RSA/ECDH is slower than symmetric).

Pre-shared keys

Server generates a session key, sends it to browser first. Requires an extra round-trip and secure key storage.

For our use case—protecting against passive logging and edge inspection—ephemeral symmetric keys are sufficient and keep latency minimal.

Trade-offs and Lessons

Performance impact

We measured ~5-15ms overhead for the full encrypt/decrypt cycle on typical documents. Web Crypto is fast (often hardware-accelerated), and for a document processing workflow, this is negligible.

Backwards compatibility

Our API accepts both encrypted and plaintext requests. If a client sends textItems directly (no encrypted field), it still works. This let us deploy without breaking existing integrations.

Debugging complexity

Can't just look at request logs anymore. We added structured logging that shows request metadata (size, timing, detection count) without the actual content.

Was it worth it?

For a privacy-focused SaaS processing sensitive documents, yes. The security improvement is real (defense-in-depth), and the marketing value is significant (enterprise security questionnaires, trust signals). Implementation took about 4 hours. The ROI seems positive.

Wrapping Up

Application-layer encryption isn't always necessary, but for products handling sensitive data, it's a meaningful security and trust signal. The Web Crypto API makes implementation straightforward, and the performance overhead is minimal.

If you're building something similar, our approach (ephemeral AES-256-GCM keys per request, encrypt both directions) is a good starting point. Adjust based on your threat model.

Questions? Feedback? Get in touch or check out our security page for more details.

SR

SafeRedact Engineering

Building privacy-first document tools

Share this post:

Related