PII Redaction in Session Replay: Patterns That Work in Production
How PII redaction actually works in session replay — rrweb block/mask/ignore, network and console sanitization, and the GDPR data-minimisation case for doing it client-side, before data ever leaves the browser.
PII redaction in session replay means stripping personally identifiable data from a recording before it is stored. Done right, it runs in the user's browser — rrweb blocks, masks, or ignores sensitive DOM nodes, and custom sweeps sanitize console logs and network bodies — so raw PII never leaves the device. That aligns with GDPR data minimisation: collect only what is necessary, no more.
Why session replay is a privacy risk in the first place
Session replay tools like rrweb do not record video — they serialize the DOM and stream its mutations so the session can be reconstructed pixel-for-pixel later. That fidelity is exactly what makes it a privacy hazard: everything the page renders is a candidate for capture. Input fields, a dashboard that prints the signed-in user's name and order history, a support widget showing an email thread, a token sitting in location.search — all of it is in the DOM, and a naive recorder will faithfully snapshot every character.
Network and console captures widen the blast radius further. A typical SaaS app logs the authenticated user's id and email on every request for debugging, and ships Authorization: Bearer eyJ... headers on each call. Per MDN, the Authorization header carries credentials directly (Basic auth is just base64-encoded user:password) — capture it verbatim and you have logged a live secret into your replay store.
OWASP's Cryptographic Failures guidance frames the whole problem in one sentence: "Data that is not retained cannot be stolen." The safest byte of PII is the one you never captured. Redaction is how you make sure session replay honours that rule instead of quietly violating it.
What counts as PII in session-replay context
GDPR Article 4 defines personal data as "any information relating to an identified or identifiable natural person." In replay context that spans the obvious (names, emails, government IDs, credit cards) and the context-dependent: a user id in a URL, an IP in a console.log, an order number that maps back to a customer. When in doubt, default-deny is safer than default-allow — treat unknown fields as sensitive until proven otherwise.
The mistake teams make is redacting only the obvious PII (the email input, the credit-card field) and missing the implicit PII bleeding through console logs, network payloads, OAuth tokens in URLs, and webhook bodies full of subscriber emails. Those surfaces leak more real PII than the form fields everyone remembers to mask.
Client-side vs server-side redaction
The single most important decision is where redaction runs. Client-side redaction transforms the data inside the user's browser, before anything is buffered for upload — the approach BugMojo takes, and the one every privacy-first vendor converges on. PostHog states plainly that its privacy controls "run in the browser or mobile app, so masked data is never sent over the network." OpenReplay is equally explicit: obscured or ignored data "will never leave the user's browser."
Server-side redaction — upload everything, scrub it after it lands — is easier to build and lets you re-mask historical data retroactively, but it defeats the core privacy goal: the raw PII has already crossed your wire and sat, however briefly, in your infrastructure. If your ingestion tier is breached, client-side redaction protects you and server-side does not.
| Feature | Client-side (in browser) | Server-side (after upload) |
|---|---|---|
| Raw PII leaves the user's browser | Never | Yes, then scrubbed |
| Aligns with GDPR data minimisation | ✓ | — |
| Protects you if the backend is breached | ✓ | — |
| Can re-mask historical recordings retroactively | — | ✓ |
| Adds runtime weight to the user's page | Small | None |
| Rules can change without a client redeploy | — | ✓ |
rrweb's masking primitives: block vs mask vs ignore
rrweb ships three distinct privacy mechanisms, and choosing the right one per element is what separates a usable redacted replay from a wall of grey boxes. Per the rrweb guide:
- Block —
blockClass/blockSelector(default class.rr-block). The element is not recorded at all and is replaced with a same-size placeholder. Strongest, but you lose all context. - Mask —
maskTextClass/maskTextSelectorandmaskAllInputs(default class.rr-mask). The element is still recorded, but its text content and input values are replaced with masked characters, so layout and interactions stay intact. - Ignore —
ignoreClass/ignoreSelector(default class.rr-ignore). The element is recorded, but input value changes on it are dropped via the input handler.
The rule of thumb: mask by default, block only the truly sensitive. Masking a payment field keeps its red validation border visible so you can still debug the form; blocking it turns the whole region into a mystery box.
<!-- BLOCK: nothing here is recorded (ID document upload) -->
<div class="rr-block">
<img src="/passport-scan.png" alt="user id document" />
</div>
<!-- MASK: shape recorded, characters replaced (billing name) -->
<span class="rr-mask">Ada Lovelace</span>
<!-- IGNORE: element recorded, input value changes dropped -->
<input class="rr-ignore" name="coupon" />Three layers of redaction (DOM, console, network)
A production-grade pipeline has three independent layers, each running in the browser before events are buffered for upload. If one layer leaks, the others act as defence in depth: DOM input masking (handled by rrweb), a text-node regex sweep (custom), and network/console payload sanitization (custom).
DOM layer: rrweb input masking
rrweb's built-in masking handles inputs cleanly. Set maskAllInputs to default-mask everything, then selectively allow the specific inputs you actually want to capture. Inputs are masked by default in modern versions precisely because they so often hold emails or passwords:
import * as rrweb from 'rrweb';
rrweb.record({
emit: bufferEvent,
maskAllInputs: true,
maskInputOptions: {
password: true,
email: true,
tel: true,
text: true, // mask plain text inputs by default
textarea: true,
color: false, // safe to capture
date: false,
number: false,
},
// CSS selector for explicit block regions
blockSelector: '.rr-block, [data-rr-block]',
// Mask text nodes too, not just inputs
maskTextSelector: '.rr-mask, [data-pii]',
});Text-node layer: regex sweep
Inputs aren't the only PII source in the DOM. Marketing pages render the user's name in a greeting, dashboards show totals with card last-4s, support widgets surface email threads. A text-node regex sweep runs before serialization and replaces matched substrings with placeholders — the same pattern OpenReplay uses when it obscures emails inside text elements by default.
Network layer: header + body sanitization
Console and network captures are the leakiest surface. Headers like Authorization, Cookie, X-Api-Key, and Set-Cookie should be dropped or hashed entirely. Bodies (JSON, form-data) should be regex-swept the same way text nodes are, and query strings should be sanitized because tokens routinely ride in URLs.
A PII regex catalog you can ship today
The patterns below cover the large majority of real-world PII without producing significant false positives. Compose them into a single sweep applied to text nodes, console arguments, network bodies, and URL query strings. The credit-card pattern is gated behind a Luhn checksum to reduce false hits on order numbers.
const PII_PATTERNS = {
email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
phoneUS: /\b(\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
phoneIntl: /\b\+\d{1,3}[\s-]?\d{2,4}[\s-]?\d{2,4}[\s-]?\d{2,4}\b/g,
ssn: /\b\d{3}-\d{2}-\d{4}\b/g,
creditCard: /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g, // post-Luhn
iban: /\b[A-Z]{2}\d{2}[A-Z0-9]{4}\d{7}([A-Z0-9]?){0,16}\b/g,
jwt: /\beyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\b/g,
authHeader: /\b(authorization|bearer|x-api-key|x-auth-token)\s*[:=]\s*\S+/gi,
ipv4: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
awsAccessKey: /\bAKIA[0-9A-Z]{16}\b/g,
githubToken: /\bghp_[A-Za-z0-9]{36}\b/g,
stripeKey: /\b(sk|pk)_(test|live)_[A-Za-z0-9]{24,}\b/g,
};
function luhnValid(cc: string): boolean {
const digits = cc.replace(/\D/g, '').split('').reverse().map(Number);
let sum = 0;
for (let i = 0; i < digits.length; i++) {
const d = i % 2 === 1 ? digits[i] * 2 : digits[i];
sum += d > 9 ? d - 9 : d;
}
return sum % 10 === 0;
}
export function redactPii(input: string): string {
let out = input;
for (const [name, pattern] of Object.entries(PII_PATTERNS)) {
out = out.replace(pattern, (match) => {
// Only redact CC if Luhn passes — reduces false positives on order numbers
if (name === 'creditCard' && !luhnValid(match)) return match;
return `[REDACTED:${name}]`;
});
}
return out;
}Wiring redaction into rrweb's pipeline
The redaction sweep runs before the event is added to the buffer. rrweb's emit callback fires for every event; intercept it, run the sweep over any text content in the event, then forward the redacted event to the actual buffer. This keeps redaction in a single chokepoint that is easy to audit and unit-test.
import * as rrweb from 'rrweb';
import type { eventWithTime } from '@rrweb/types';
rrweb.record({
emit(event: eventWithTime) {
const redacted = redactEvent(event);
captureBuffer.push(redacted);
},
maskAllInputs: true,
// ... other config
});
function redactEvent(event: eventWithTime): eventWithTime {
// FullSnapshot — sweep text nodes in the serialized tree
if (event.type === 2) {
redactSerializedTree(event.data.node);
return event;
}
// IncrementalSnapshot — mutation source has text changes
if (event.type === 3 && event.data.source === 0) {
for (const t of event.data.texts) t.value = redactPii(t.value);
}
return event;
}Compliance framing: data minimisation and PCI
The regulatory case for client-side redaction is direct. The ICO's data-minimisation guidance (GDPR Article 5(1)(c)) requires personal data to be "adequate, relevant and limited to what is necessary," and warns you "must not collect or retain personal data on the off-chance that it might be useful in the future." A session replay that captures raw PII "just in case" is collecting more than it needs — the definition of a minimisation failure. Redacting at the source is how you demonstrate you only hold what the debugging purpose requires.
Card data carries its own regime. OWASP's Cryptographic Failures entry recommends you "don't store sensitive data unnecessarily" and, where you must, use PCI-DSS-compliant tokenization or truncation — store the last four digits plus a processor token, never the full PAN. The same logic maps onto replay: never let a full card number enter the recording, and Luhn-gate the fields where one might appear.
GDPR consent flow architecture
Consent is a separate requirement from masking, and it has three states: unset (first visit, recording disabled), granted (opted in, recording active), and denied (opted out, no further prompts). The state lives in chrome.storage.local for extensions or localStorage for in-page SDKs. Recording is gated on state === 'granted', and the dialog must appear before any capture data is buffered.
type ConsentState = 'unset' | 'granted' | 'denied';
async function getConsent(): Promise<ConsentState> {
const { consent } = await chrome.storage.local.get('consent');
return consent ?? 'unset';
}
async function ensureConsent(): Promise<boolean> {
const state = await getConsent();
if (state === 'granted') return true;
if (state === 'denied') return false;
// unset — show the dialog, await the user's choice
const choice = await showConsentDialog();
await chrome.storage.local.set({ consent: choice });
return choice === 'granted';
}The dialog should disclose what's recorded (DOM, inputs masked, console, network with PII redacted), how long it's retained, who has access, and a link to the privacy policy. It must be dismissible without granting consent — the user must be able to say "no" in a single click, because implied consent by merely visiting the site is not consent under GDPR.
Caveats: when this isn't the right fit
Redaction is a set of trade-offs, not a free win. Three honest caveats:
- Over-masking destroys debugging value. The whole reason to record sessions is to see what went wrong. Block too aggressively and you get a replay of grey rectangles that reproduces nothing. Prefer mask over block, keep non-sensitive UI visible, and resist the urge to blanket-block whole pages — a recording you can't learn from is as useless as no recording at all.
- Masking is not access control. Redaction limits what enters the recording; it says nothing about who can watch what got through. You still need authenticated, least-privilege access to replays, short retention windows, and audit logging on who viewed which session. A perfectly masked replay behind an open dashboard is still a breach waiting to happen.
- Client-side rules can't be changed retroactively. If a new PII field ships and your sweep didn't know about it, past recordings already captured it. Client-side redaction trades retroactive control for the guarantee that raw PII never left the browser — usually the right trade, but know you're making it.
Pitfalls we hit in production
- Async masking race. Input events fired before a mask propagated to the DOM, leaking 1–2 characters per keystroke for a ~50ms window. Solution: capture inputs synchronously in the rrweb event handler rather than relying on a downstream DOM mutation.
- False-positive credit-card matches. A 16-digit order number that happened to pass Luhn got redacted. Solution: combine Luhn with a context-aware filter (only match inside input values and known billing-field selectors).
- Compressed body leaks. gzip-compressed network bodies bypassed the regex sweep because we scanned before decompressing. Solution: decompress before redaction and recompress after, or filter on content type and skip capture of compressed payloads.
Auditing your own redaction
A fuzz test belongs in CI. Build a corpus of ~50 known-bad strings (an email, a credit card, an SSN, a JWT, an AWS key), inject them into a test page, trigger a capture, then assert none of those exact strings appear in the buffered events. Run it on every PR so a regression fails the build instead of shipping.
The fuzz test should also cover false-positive cases — a 16-digit order ID, an internal username containing an @, a UUID that looks like a JWT prefix. If those get redacted, developer experience suffers; if PII gets through, you have a breach. Track both error rates, not just the leak rate.
Common mistakes
- Doing redaction server-side. Once data hits your wire, it has already been collected — at odds with data minimisation, and exposed if your backend is breached.
- No consent dialog. "Implied consent" by visiting the site is not GDPR consent.
- Forgetting query-string secrets. Tokens in URLs are PII too — sweep
location.searchand any captured URLs. - Blocking when you could mask. Blanket blocking is the lazy default that silently kills your replays' debugging value.
- Trusting defaults blindly.
maskAllInputsis on by default in modern rrweb, but text-node masking is not — you must opt in withmaskTextSelector.
Next steps
Start with the rrweb guide for the authoritative masking config, then compare how PostHog and OpenReplay expose the same block/mask/ignore ideas — cross-reading three implementations is the fastest way to internalise the model.
BugMojo runs PII redaction client-side, before capture data leaves the browser, on top of rrweb DOM recording (no video in V1) across its Chrome, Firefox, and Edge extension — so the console logs, network requests, and screenshots it captures for AI coding agents over MCP are scrubbed at the source, the way this article argues they should be.
Frequently asked questions
Frequently asked questions
Sources
- GDPR Article 4 — Definitions — gdpr-info.eu (2018)
- Principle (c): Data minimisation — Information Commissioner's Office (ICO) (2023)
- A02:2021 — Cryptographic Failures — OWASP (2021)
- rrweb guide — privacy and masking options — rrweb-io (2026)
- PostHog — Session replay privacy controls — PostHog (2026)
- OpenReplay — Sanitize Data (Web) — OpenReplay (2026)
- HTTP Authorization header — MDN Web Docs (2026)
Get bug-tracking insights, weekly.
Engineering deep-dives, QA playbooks, and honest tool comparisons. No spam — unsubscribe in one click.

