Building Systems That Don't Spy: A Lesson in Traffic Mirroring and Privacy
Building Systems That Don't Spy: A Lesson in Traffic Mirroring and Privacy
The Confession
I once deployed a debugging tool that, in retrospect, was basically a wiretap on my own users. I was running a solo SaaS, chasing a nasty race condition in production that only happened sporadically. Out of pure frustration, I wrote a quick piece of middleware that mirrored every single incoming HTTP request. I copied headers, session tokens, along with raw payloads, and dumped the data into a hidden logging database. I thought I was being a clever system architect. Instead, I had built my own little dragnet surveillance machine.
When we look at historical privacy breaches, we often hear about physical traffic splitters. You know the stories: secret rooms where fiber optic cables are literally duplicated to send copies of the internet backbone to off-site intelligence servers. Half the tech community loses their minds over the state doing it, but then we go and build the exact same architecture in our Node.js applications. We pipe raw user data into observability platforms. We do the same with error trackers and analytics dashboards without a second thought.
I genuinely don't know how I thought my logging setup was a good idea at the time. Debugging production is hard, but violating user trust is worse.
The Problem: The "Big Brother" Middleware
The core issue is that copying traffic is trivially easy, but securing that copied traffic is agonizingly hard. If you duplicate an entire request stream, you are assuming the destination is as secure as your primary database. It rarely is. Logging services get compromised or API keys leak. Suddenly your innocent debugging tool becomes a massive liability.
In software engineering, this anti-pattern usually takes the form of a greedy interceptor. We tell ourselves we only want to log error states, but we end up capturing passwords along with personal messages and payment tokens.
Here is what my catastrophic early implementation looked like. I am sharing this so you can point and laugh, but mostly so you never write it yourself:
import express, { Request, Response, NextFunction } from 'express';
import { ExternalLogger } from './services/logger';
const app = express();
app.use(express.json());
// 🚨 THE DRAGNET ANIT-PATTERN 🚨
// Do not use this in production. Ever.
const blindTrafficSplitter = (req: Request, res: Response, next: NextFunction) => {
// We clone the entire payload. We don't care what it is.
const trafficCopy = {
path: req.path,
headers: req.headers,
body: req.body,
query: req.query,
timestamp: new Date().toISOString()
};
// Fire and forget to a third-party observability tool
// Congratulations, you just leaked all your user data.
ExternalLogger.dump(trafficCopy).catch(console.error);
next();
};
app.use(blindTrafficSplitter);This is a digital fiber splitter. It sits on the main circuit and copies everything without inspection before forwarding the data to an opaque destination. If your application handles health data or financial records, or even just private messages, you have just breached your own security boundary.
The Solution: Boundary Redaction and Ephemerality
The fix requires two architectural shifts. First, we stop mirroring raw traffic. We implement aggressive data minimization at the boundary edge. Second, we lean heavily into cryptographic ephemerality.
Even if someone intercepts our internal network traffic, the payloads should be useless. Perfect Forward Secrecy (PFS) in network protocols ensures that compromising a long-term key does not compromise past session keys. We can adopt a similar mindset for our application data: if you must log, log redacted states. If you must transmit sensitive data internally between microservices, wrap it in ephemeral encryption.
We treat our internal network with the same hostility we treat the public internet.
Implementation: From Dragnet to Zero-Trust
Let's tear down the surveillance middleware and replace it with a system that respects data boundaries.
First, we build a strict redaction layer. Instead of copying everything, we allow an explicit safelist of fields to pass through to our observability tools, and we mask everything else.
import { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
// Define exactly what we are allowed to look at
const SAFE_HEADERS = ['user-agent', 'x-request-id', 'accept-language'];
const SENSITIVE_KEYS = ['password', 'token', 'creditCard', 'ssn', 'message_body'];
function redactPayload(payload: any): any {
if (!payload || typeof payload !== 'object') return payload;
if (Array.isArray(payload)) {
return payload.map(redactPayload);
}
const redacted = { ...payload };
for (const key of Object.keys(redacted)) {
if (SENSITIVE_KEYS.includes(key.toLowerCase())) {
redacted[key] = '[REDACTED]';
} else if (typeof redacted[key] === 'object') {
redacted[key] = redactPayload(redacted[key]);
}
}
return redacted;
}
export const secureObservabilityMiddleware = (req: Request, res: Response, next: NextFunction) => {
const safeHeaders = Object.fromEntries(
Object.entries(req.headers).filter(([key]) => SAFE_HEADERS.includes(key.toLowerCase()))
);
const sanitizedLog = {
path: req.path,
method: req.method,
headers: safeHeaders,
body: redactPayload(req.body),
requestId: crypto.randomUUID()
};
// Now we safely log, knowing PII is stripped at the edge.
process.stdout.write(JSON.stringify(sanitizedLog) + '\n');
next();
};That handles the logging layer. But what about internal service-to-service communication? If you have an API gateway routing traffic to a background worker, and someone taps your internal VPC, they get the raw data.
To counter this, I use a pattern inspired by forward secrecy for internal payloads. We generate a symmetric key for the specific payload and encrypt the data. Then, we encrypt the symmetric key with the destination service's public key. It adds overhead, yes. But it guarantees that your routing infrastructure never sees plaintext.
import { generateKeySync, publicEncrypt, privateDecrypt, randomBytes, createCipheriv, createDecipheriv } from 'crypto';
// In a real system, these live in a secure KMS.
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
});
interface EncryptedEnvelope {
encryptedKey: string; // Base64
iv: string; // Base64
authTag: string; // Base64
ciphertext: string; // Base64
}
export class PayloadProtector {
/**
* Wraps a payload in an encrypted envelope.
* The router/message broker only ever sees this envelope.
*/
static seal(payload: object, destinationPublicKey: crypto.KeyObject): EncryptedEnvelope {
// 1. Generate an ephemeral symmetric key and IV for this specific message
const ephemeralKey = randomBytes(32);
const iv = randomBytes(12);
// 2. Encrypt the actual payload using AES-256-GCM
const cipher = createCipheriv('aes-256-gcm', ephemeralKey, iv);
const plaintext = JSON.stringify(payload);
let ciphertext = cipher.update(plaintext, 'utf8', 'base64');
ciphertext += cipher.final('base64');
const authTag = cipher.getAuthTag().toString('base64');
// 3. Encrypt the ephemeral key with the destination's public key
const encryptedKey = publicEncrypt(
{
key: destinationPublicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
ephemeralKey
).toString('base64');
return { encryptedKey, iv, authTag, ciphertext };
}
/**
* Unwraps the envelope at the destination service.
*/
static unseal(envelope: EncryptedEnvelope, localPrivateKey: crypto.KeyObject): any {
// 1. Decrypt the ephemeral key
const ephemeralKey = privateDecrypt(
{
key: localPrivateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
Buffer.from(envelope.encryptedKey, 'base64')
);
// 2. Decrypt the payload
const decipher = createDecipheriv('aes-256-gcm', ephemeralKey, Buffer.from(envelope.iv, 'base64'));
decipher.setAuthTag(Buffer.from(envelope.authTag, 'base64'));
let plaintext = decipher.update(envelope.ciphertext, 'base64', 'utf8');
plaintext += decipher.final('utf8');
return JSON.parse(plaintext);
}
}
// Usage example:
// const secretData = { userId: 123, ssn: "000-00-0000" };
// const safeTransmission = PayloadProtector.seal(secretData, publicKey);
// console.log("Router sees:", safeTransmission);Notice the difference here. The router and message queue never see the plaintext ssn. The observability tools are similarly blind to it. The ephemeral key exists only for the millisecond it takes to encrypt and decrypt the message. If a database holding these envelopes is dumped five years from now, the payloads remain locked.
Conclusion
Our goal in building systems is to solve problems. Hoarding data creates unnecessary risk. Leaving massive footprints of plaintext data across your infrastructure is a ticking clock.
The moment you duplicate an entire stream of traffic just to "keep an eye on things," you act like an uninvited intelligence agency. Write tests and use typed contracts. Apply redaction at the edge. Leave the dragnet surveillance to the bad guys.