Redacting Sensitive Data in Webhook Payloads
Webhook bodies end up in logs, dashboards, and error trackers more often than anyone intends. A practical approach to stripping out what shouldn't be there without breaking replay or debugging.
Webhook payloads carry more than the event itself. Customer emails, card metadata, internal tokens, and provider signature headers routinely ride along in the same request body that also happens to be the easiest thing to log. It's worth reading what's actually sensitive in a webhook payload first if you haven't thought about this before. This post covers doing something about it.
The default is worse than people assume
The typical webhook handler logs the incoming request for debugging, somewhere near the top of the function:
app.post('/webhooks/stripe', (req, res) => {
logger.info('Webhook received', { body: req.body, headers: req.headers });
// ...
});
That single line can put a customer's card brand and last four digits, their email, and your provider's signing secret material into whatever logging pipeline you use, often a third-party service with its own retention policy you don't control. It works fine until a security review or a compliance questionnaire asks where that data ends up.
Redact at the boundary, not at the source
The instinct is often to sanitize the payload as early as possible, before it touches any of your code. This has a real cost: if you strip fields out before storing the event, you can't replay it later, and you can't answer "what did this webhook actually contain" if a customer disputes what happened.
A better boundary: keep the original, unmodified payload in your primary store. Redact at the point where data crosses into something you don't fully control, logs, error trackers, a support dashboard, rather than at the point of receipt.
// Store the original, unredacted, for replay and disputes
await db.webhookEvents.create({ data: { raw: req.body, headers: req.headers } });
// Redact only for the paths that leave your primary boundary
logger.info('Webhook received', { body: redact(req.body), headers: redact(req.headers) });
A field-path redaction approach
Rather than trying to pattern-match sensitive-looking values, an explicit list of known field paths is more predictable and easier to review in a security audit.
const SENSITIVE_HEADERS = [
'authorization',
'cookie',
'stripe-signature',
'x-hub-signature-256',
'x-api-key',
];
const SENSITIVE_PATHS = [
'data.card.number',
'customer.email',
'metadata.internal_token',
];
function redactHeaders(headers: Record<string, string>) {
const out = { ...headers };
for (const key of Object.keys(out)) {
if (SENSITIVE_HEADERS.includes(key.toLowerCase())) out[key] = '[redacted]';
}
return out;
}
function redactPayload(payload: Record<string, unknown>, paths: string[]) {
const out = structuredClone(payload);
for (const path of paths) {
setAtPath(out, path, '[redacted]');
}
return out;
}
function setAtPath(obj: Record<string, unknown>, path: string, value: unknown) {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (typeof current[parts[i]] !== 'object' || current[parts[i]] === null) return;
current = current[parts[i]] as Record<string, unknown>;
}
const last = parts[parts.length - 1];
if (last in current) current[last] = value;
}
This handles known fields precisely and leaves everything else intact, which matters for debugging: a payload that's been aggressively wildcard-redacted is often less useful than the bug it was meant to help you find.
A path-based approach only catches fields you knew to list. When a provider adds a new field to a payload (a new nested object with a customer's phone number, say), it won't be redacted until someone notices and updates the list. Review the list whenever a provider changes its payload schema, and treat this as an ongoing maintenance task, not a one-time setup.
Header redaction deserves the same care as body redaction
It's easy to focus entirely on the JSON body and forget that headers carry sensitive data too: Authorization, Cookie, and every provider's own signature header (which is effectively a piece of key material, even if it's not a secret in the traditional sense). Any logging that includes req.headers wholesale should run through the same redaction path as the body.
What redaction shouldn't touch
Whatever you redact for logs, keep the original intact for two things: forwarding the event to wherever it actually needs to go, and replaying it later if something needs to be reprocessed. A signature header that's been redacted can't be used to verify a replayed request, and a redacted card number can't help resolve a genuine billing dispute. Redaction is about what humans see in a log viewer, not what your system does with the data.
The data masking docs cover the version of this built into Webhook Watchtower: the raw event is always stored and forwarded unchanged, while the dashboard view hides known-sensitive headers and whatever payload field paths you configure. Same boundary, applied to the event viewer instead of your own logs.
Frequently asked questions
What's the difference between redacting for logs and redacting for storage?
Logs typically leave your control: they go to a third-party aggregator, get retained on a schedule you don't fully manage, and are visible to more people than your primary database. Storage you control directly. It's reasonable to redact more aggressively for logs than for your primary event store, where you may still need the original data for replay or dispute resolution.
Should I redact before or after storing the payload?
Keep the original, unredacted payload in your primary store if you might need to reprocess or replay the event later. Redact at the point where data leaves that controlled boundary: when it's written to logs, sent to an error tracker, or displayed in a UI that more people have access to than your database.
How do I redact fields I don't know about in advance?
You can't fully. Pattern-based redaction (matching things that look like card numbers, emails, or tokens by shape) catches some unknowns, but it's a safety net, not a substitute for an explicit list of known-sensitive fields per provider. New fields a provider adds later won't match a hand-picked list until someone notices and adds them.
Share this post
Webhook Watchtower
Stop debugging webhooks in logs.
Capture every event, replay failures, and get alerted when deliveries fail, without changing your existing setup.
Try it free →Related posts
Monitoring Webhook Health: The Metrics That Actually Matter
Uptime doesn't tell you whether webhooks are working. A short list of the signals that actually indicate a healthy webhook pipeline, and what each one catches that the others miss.
Alerting on Webhook Failures Without Alert Fatigue
Notify on every failed delivery attempt and you'll train people to ignore the channel. A pattern for alerting on what actually needs a human, and staying quiet about what doesn't.
IP Allowlisting vs Signature Verification: What Actually Secures a Webhook Endpoint
IP allowlisting feels like a solid first line of defence for a webhook endpoint. It isn't, and understanding why makes the case for signature verification much clearer.