A PII Masking Checklist for Session Replay
The first time I audited my own session replay setup, I found a user’s full name and email rendered in the account header of every single recording, a password reset token sitting in a captured URL, and a support widget cheerfully displaying the user’s message history. I had configured input masking and considered the job done. It was maybe a third done.
That audit is why this checklist exists. If you want to properly mask PII in session replay, you have to work through every channel the recorder captures — and inputs, the only channel most people configure, are just the first of six.
One framing note before the list: mask by default and unmask deliberately. Every replay tool I’ve used lets you invert the logic — record everything, block specific fields — and that mode fails unsafe. A new form field added eight months from now by a teammate who’s never read your masking config will leak. Default-mask fails safe: the new field shows up as asterisks until someone consciously decides otherwise.
How to mask PII in session replay, field by field
1. Input fields
The table stakes, but with edges people miss:
type=password— every serious tool masks this unconditionally. Verify anyway; you’re one config flag away from disaster.- All other input types — turn on mask-all-inputs.
email,tel, and plaintextfields holding names and addresses are PII just as much as passwords. - Autocomplete attributes — fields with
autocomplete="cc-number",cc-csc,current-password,one-time-codeand friends are self-labeling as sensitive. Some recorders key masking off these attributes; either way, they’re a ready-made audit list of your most dangerous fields. The full token list is in the HTML spec’s autofill section. contenteditableelements and rich-text editors — these are not inputs. They’re DOM text, captured through mutation records, and mask-all-inputs does nothing for them. If your app has a comment box built on ProseMirror or similar, treat it as a text node (next section).- File inputs — the file contents don’t upload to the recorder, but the filename can appear in the DOM, and
tax-return-jane-doe-2025.pdfis PII.
2. Text nodes — the channel everyone forgets
Inputs are what users type. Text nodes are what your app renders: the account page showing name and address, the invoice table, the admin panel listing customer emails, the ARIA live region that announces “Verification code sent to +1 555…”.
You have two workable strategies:
- Mask all text and selectively unmask your app’s chrome (nav labels, button text, headings). Strictest, and what I’d pick for anything handling health or financial data. Replays become wireframe-like but still perfectly debuggable — layout, clicks, errors, and network activity all survive.
- Selectively mask known-sensitive regions by class or attribute. More readable replays, but you’re back to failing unsafe, so it demands the verification procedure at the end of this article as a standing habit.
Most tools in the rrweb lineage support a masking class convention. In LogReplay, for instance, the recorder config takes the strict route in a few lines:
LogReplay.init('YOUR_PROJECT_ID', {
privacySetting: 'strict', // mask all text and inputs by default
maskTextSelector: '*',
unmaskTextSelector: '[data-lr-unmask]', // opt chrome back in explicitly
});
Then sprinkle data-lr-unmask on your nav and static UI labels. Ten minutes of work, and new features are private by default.
3. URLs and query parameters
Recorders capture the page URL on every navigation, and URLs leak more than people think: password-reset and magic-link tokens, email addresses in query params (?email=… from your own marketing links), OAuth code and state values mid-flow, and path segments like /patients/jane-doe.
Two layers of defense, and you want both:
- Fix the URLs themselves. Tokens belong in POST bodies or fragments, identifiers belong opaque (
/patients/8f3c2a). This helps everywhere — server logs, browser history, Referer headers — not just replay. Your replay audit is often the first time anyone actually looks at what’s in your URLs, which is a grim little bonus. - Configure URL redaction in the recorder for params you can’t eliminate, so
?token=abc123is stored as?token=[REDACTED].
4. Network request and response bodies
If your replay tool captures network activity alongside the DOM — the engineering-focused ones do, and it’s half their value — then every API payload is a second copy of your data flowing into replay storage. Your masked login form is nice; the POST /api/login body with the plaintext password sitting in the network tab is a hole you could drive a truck through.
- Confirm whether body capture is even on. Header-and-status-only is a legitimate choice that keeps most debugging value.
- If bodies are on, maintain a deny-list of URL patterns whose bodies are never recorded: auth endpoints, payment endpoints, profile endpoints, anything under
/admin. - Redact known-sensitive header names (
Authorization,Cookie,Set-Cookie) — most tools do this by default, but “most” and “default” are both words to verify, not trust. - Remember GraphQL: one endpoint, so URL-pattern deny-lists don’t work. You need operation-level or key-based redaction (
password,ssn,token,emailas JSON keys).
5. Custom data attributes and hidden DOM state
The DOM your users see is a subset of the DOM the recorder captures. Things I’ve found in my own markup during audits: data-user-email attributes left over from an analytics experiment, JSON blobs of user objects in <script type="application/json"> tags for hydration, and entire hidden admin panels rendered with display:none instead of not rendered at all.
display:none is invisible to humans and fully present in mutation records. If it’s in the DOM, it’s in the replay. Grep your codebase for data- attributes carrying user values, and prefer not-rendering over hiding for anything sensitive.
6. Third-party widgets
Support chat (Intercom, Zendesk), embedded checkout, analytics survey popups, social embeds. Two cases with opposite problems:
- Cross-origin iframes (most payment elements) can’t be read by the recorder at all. Safe by construction — verify the frame really is cross-origin and move on.
- Same-origin, script-injected widgets (many chat widgets inject straight into your DOM) are fully visible, including the user’s entire support conversation. These need an explicit block rule on the widget’s container element so the recorder skips the subtree entirely.
You don’t control when these vendors change their DOM structure, so block by the stable container ID, not by internal classes.
The verification procedure
Config is a hypothesis. The replay is the test. Run this before you trust anything, and re-run it quarterly and after big frontend changes:
- On production (or a prod-identical staging build — with masking, the differences are exactly where the bugs live), open a fresh browser profile with recording active.
- Walk every sensitive flow as a real user: sign up, log in, view and edit your profile, enter a test credit card, open the support widget, trigger a password reset and click the email link, visit anything admin-ish.
- Watch your own replay with fresh eyes, DOM-inspecting as you go. Check the URL bar at every step, the network tab for every auth and payment call, and pause on every screen that renders account data.
- Log every leak, fix, re-record. Repeat until a full pass is clean.
- Write down the date and what you checked. When a customer’s security questionnaire asks how you protect PII in analytics tooling — and under GDPR you should expect the question — “we run a recorded masking audit quarterly, here’s the log” is a real answer. It also feeds directly into the lawful-basis story you need for running session recording under GDPR at all.
Thirty minutes, once a quarter. Cheap insurance against the genuinely bad day where the leak you find is in a recording of someone else.
See the bug the way your user did
LogReplay captures session replays, console output, network requests, and errors in one timeline — so you stop guessing what happened before the ticket arrived.
Try LogReplay free