Session replay with masking by default
Record what your users actually saw with PostHog session replay, masking sensitive data by default so the recording never leaks.
Three users this week told support the dashboard “broke.” Sentry is clean: no exception, no stack trace. Your logs show every request from those visits returning a 200. On a call, the user repeats the steps and it works fine. Nothing in your observability stack can explain it, because nothing actually threw.
This is a UX bug: the code didn’t crash, the interaction failed. A modal snapped shut, or a click landed on an invisible overlay instead of the button behind it. The user did something real and the app responded wrong, and none of it reaches an error report or a log line. Those tools record what the server saw, not what the user saw.
Session replay records what the user saw, frame by frame. It is PostHog’s fourth and final primitive, after events, properties, and feature flags, and it answers the one question the others can’t: what did this user actually do? It is also the easiest primitive to turn into a privacy incident, so most of this lesson is about controlling what it captures.
Where replay fits among errors, logs, and analytics
Section titled “Where replay fits among errors, logs, and analytics”Each observability layer you’ve built answers a different question. Replay sits at the bottom, the single-user, frame-by-frame layer.
| Tool | Answers the question | Granularity |
|---|---|---|
| Sentry | What exception was thrown, and where in the code? | One error, with a stack trace |
| Structured logs | What did this request do on the server? | One request |
| Product analytics | What are users doing, in aggregate? | Cohorts and funnels |
| Session replay | What did this user actually do, on screen? | One user, one session |
The bottom row is the only one that captures interaction the code never reacted to. Sentry needs a thrown error to report; logs need a request to write a line; analytics tells you conversion dropped on the pricing page, not why this user bailed. Replay sees the misfired click, the modal that closed itself, the form the user gave up on.
So replay complements Sentry, it doesn’t replace it. When a bug throws, Sentry catches it; when a bug doesn’t throw, replay is the only witness. A mature web app runs both and pivots between them on a single ticket.
Replay records the DOM, not a video
Section titled “Replay records the DOM, not a video”PostHog does not record your screen. There is no video file. Session replay sounds like a screen recording, but it works differently, and the difference is what makes masking trustworthy.
PostHog serializes the DOM . It takes one snapshot at the start, then records a stream of changes as the user interacts: a node added, a class toggled, some text updated. It captures these mutations with rrweb , alongside mouse movement, clicks, scroll position, and viewport size, plus console lines and network request metadata: the URL, method, status code, and duration. The player replays that stream by re-rendering the recorded DOM, frame by frame. What you watch is your own app’s HTML and CSS, not a movie of someone’s screen.
Notice what is missing: request and response bodies. By default the payload of every fetch stays out of the recording, so you get the URL and the status but never the contents. That is deliberate, and we return to why at the end.
Because the player re-renders recorded DOM, masking is structural. When you mark a field as masked, its value is replaced with ***, or the element is dropped, at capture time inside the user’s browser, before anything is transmitted. The real value is never serialized, never sent, never stored. A blur is different: it paints a layer over pixels that still exist underneath, and pixels can be recovered. Here a masked value was never in the recording, so there is nothing to un-blur, OCR, or leak.
The screen-recording mental model misleads you the other way too. Picture a blurred screenshot and you will under-trust masking, hide the whole page to be safe, and end up with an all-grey replay that teaches you nothing. Trust the structural guarantee: the masked value left the browser as ***.
<div class="sensitive"> { tag: "input", type: "password",
value: "•••" }
{ tag: "div", class: "sensitive",
text: "•••" } POST /ingest → PostHog Cloud EU
password value: "•••"
.sensitive text: "•••" .sensitive Scrub through it and watch the value in step 1 become *** in step 2 and stay that way through steps 3 and 4. The masked value disappears in the browser, before transit.
Masking by default: two surfaces, one posture
Section titled “Masking by default: two surfaces, one posture”Default to masked, then whitelist what’s safe to show. For a web app handling customer data this is the right posture: you start from “nothing sensitive is visible” and deliberately open up the few fields that are safe.
Masking is set in two places, and you have to set both:
- The SDK config, inside the
posthog.inityou already wrote. Here your code declares what to mask. - The project’s replay settings, in the PostHog dashboard. This is a project-wide masking level that applies regardless of any individual page’s code.
The two compose: the dashboard setting is a floor the whole project sits on, and the SDK config tightens it per app. The failure mode is split-brain, where you mask carefully in posthog.init but never notice the dashboard’s default is quietly looser, or the reverse. Set both, and make them agree.
The config below is not a new file. It’s the same posthog.init from earlier in this chapter, plus three keys for session recording.
posthog.init(POSTHOG_KEY, { api_host: '/ingest', ui_host: 'https://eu.posthog.com', defaults: '2026-01-30', capture_pageview: false, opt_out_capturing_by_default: true,});The init you already have. opt_out_capturing_by_default: true is the safety floor: nothing captures until consent flips it on.
posthog.init(POSTHOG_KEY, { api_host: '/ingest', ui_host: 'https://eu.posthog.com', defaults: '2026-01-30', capture_pageview: false, opt_out_capturing_by_default: true, disable_session_recording: true, session_recording: { maskAllInputs: true, maskTextSelector: '.sensitive, [data-sensitive]', blockSelector: '.never-record, [data-no-replay]', },});The same init, plus three keys. maskAllInputs: true is the safety floor: every input value is masked. maskTextSelector adds class- and attribute-targeted masking for non-input text, and blockSelector removes matching elements from the recording entirely. disable_session_recording: true keeps recording off until consent turns it on.
The three keys do different jobs. maskAllInputs carries the load: turn it off and every form streams its values in the clear, so leave it on. maskTextSelector masks text that isn’t in an input, such as a <div> showing a customer’s name. blockSelector is a different lever: masking text versus blocking an element whole is the subject of the next section.
.sensitive and .never-record are conventions for this lesson: .sensitive means “mask this element’s text,” .never-record means “don’t record this element at all.” They’re plain CSS classes, so any names work, but pick them once and apply them consistently so the masking catalog later stays maintainable.
Mask versus block: what each one hides
Section titled “Mask versus block: what each one hides”Masking and blocking both hide sensitive content, but they hide different amounts of it, for different reasons.
Mask keeps the element in the recording. Its structure, position, dimensions, and the fact that the user interacted with it are all preserved; only the text or value is replaced with ***. In the replay you watch the user click into the password field, the focus ring land, eight characters get typed, you just don’t see which eight. Reach for mask when the interaction matters for debugging: did they fill the field, did focus land where you expected, did the validation message fire?
Block removes the element entirely. It isn’t recorded at all, and the player shows a blank placeholder of the same footprint, so you lose both the content and the fact of interaction. Reach for block when the content shouldn’t exist in the recording in any form: a third-party iframe rendering someone’s PII , a customer’s fully rendered billing details, anything whose very structure is more than you want stored.
The two tabs below show the replay player rendering the same login form, once with the password field masked and once with it blocked.
The decision rule is short: default to mask; escalate to block only when the structure itself is sensitive or the element is third-party. It balances two failure modes. Over-block and you destroy the debugging value: an all-grey replay is as useless as no replay. Under-mask and you leak. So mask aggressively and block surgically.
Those project-wide selectors from the last section have per-element counterparts, for when you want to tag one specific element at its call site rather than in the global config. You apply them right in your JSX:
<form> {/* masked: recorded, but the value shows as *** */} <input name="fullName" className="ph-no-capture" />
{/* blocked: this element is not recorded at all */} <iframe src={billingWidgetUrl} data-ph-no-capture />
{/* unmasked: opt one value back in, even under masking */} <span data-ph-capture-attribute-unmask="true">Order #{orderId}</span></form>The ph-no-capture class masks the element (the per-element version of maskTextSelector), the data-ph-no-capture attribute blocks it entirely (the per-element version of blockSelector), and data-ph-capture-attribute-unmask="true" un-masks one value even when a broader rule would hide it. That last one is the rare whitelist case, for something like an order number that support genuinely needs to read off the replay.
What to mask and what to block in a SaaS app
Section titled “What to mask and what to block in a SaaS app”The posture and the levers stay abstract until you map them onto a real app’s surfaces. Every SaaS shares the same recurring sensitive spots, so the experienced move is to decide them once: write the catalog, apply the classes, and stop re-litigating per feature. Build it by triaging the targets yourself.
The exercise below lists realistic surfaces from a B2B SaaS. Drag each into Mask (record it, hide the value) or Block (don’t record it at all): mask when the interaction matters, block when even the structure shouldn’t be stored.
Sort each surface into how a SaaS app should treat it in session replay. Drag each item into the bucket it belongs to, then press Check.
.sensitiveThe same catalog in prose, as your canonical reference:
- Mask with the
.sensitiveclass, orph-no-captureper element: customer name, address, and phone on profile pages; free-text fields where users might paste anything (notes, descriptions, comments); the username or email field on auth forms (masked text lets support confirm that a user typed an email and verify the right account, without seeing which address); and anything classed.sensitive. - Block with the
.never-recordclass, ordata-ph-no-captureper element: third-party iframes carrying PII; rendered billing and payment details; the audit-log preview (wall-to-wall customer data viewed by operators); and the Stripe Elements wrapper.
That last one needs a precise word. Stripe Elements renders its card inputs in a third-party iframe served from Stripe’s own origin. rrweb records your page’s DOM and physically cannot read across that origin boundary, so card data is never captured even with no configuration: the same-origin policy does the work. Class the wrapper .never-record anyway, for two reasons. Defense-in-depth: one less thing to get wrong if Stripe changes how Elements mounts. And signal-to-noise: it keeps an empty iframe placeholder out of the replay. So replay does not “accidentally capture the Stripe card form”; it’s already safe, and we block it on purpose.
Two gotchas come from the catalog not being write-once.
Masking config rots. The catalog is correct for the app as it exists today. Next quarter someone ships a <textarea> collecting customer shipping addresses, and unless someone classes it, it’s recorded in the clear. New sensitive surfaces arrive with every feature, which is why the privacy review at the end of this lesson is a recurring ritual, not a launch checkbox.
maskAllInputs misses contenteditable. That setting masks the real form elements, <input>, <textarea>, and <select>. It does not mask a <div contenteditable> rich-text editor, because the DOM treats it as a plain div the user can type into, not a form input. So a rich editor where customers write notes is captured in full unless you class it .sensitive by hand. Treat contenteditable as the canonical case where the default didn’t catch it.
Recording only on consent
Section titled “Recording only on consent”Replay records a specific person using your app, so it is personal data and obeys the same consent gate as every other PostHog primitive: nothing records before the user accepts. You already built that gate in the security chapter (the four-state consent machine and useConsent()) and wired it to PostHog earlier in this chapter, so this is one more line on it.
That line exists because of disable_session_recording: true in your config. With that key set, the SDK won’t record until you call posthog.startSessionRecording(), and you call it only on the accepted branch, right after opting the user in.
import('posthog-js').then(({ default: posthog }) => { // ...posthog.init(...) from earlier in this chapter posthog.opt_in_capturing(); posthog.startSessionRecording();});Withdrawal is already handled. The cleanup side of that same effect calls posthog.opt_out_capturing() when consent is revoked, and opting out stops the recording, so you don’t need a separate stopSessionRecording() for the consent path.
Then verify it, the way you verified the consent gate earlier. Open the app fresh, reject in the banner, click around, and check the replay list in PostHog: no session should appear. Do it again and accept: a session appears.
Sample recordings by value, not volume
Section titled “Sample recordings by value, not volume”Recording every session fails on two fronts. The first is cost: replay is metered, and recording 100% of a B2C site at any real scale burns through your quota in days. The second is signal. A thousand recordings of people who landed, glanced, and bounced is a haystack, and when a bug report comes in you have to find the one session that matters among hundreds where nothing happened. Past a point, recording more makes replays less useful, because the session you need is buried.
So you sample on purpose, with two mechanisms.
The first is a flat sample rate: record some fraction of all sessions, set in the same session_recording config.
session_recording: { maskAllInputs: true, sampleRate: '0.1',}Watch the value: sampleRate is a string, '0.1', not the number 0.1. Pass a number and PostHog won’t apply it as you expect, so type the quotes.
A flat rate is rarely what you want, because not all sessions are equal. The default for a B2B SaaS is to sample by who the user is. Record at or near 100% of identified users: these are your paying customers, low in volume and high in signal, and their bugs are the ones you can’t miss. Sample anonymous traffic around 10%. You want the customer’s broken upgrade flow, not a bounce from an ad.
The second mechanism is more surgical: trigger groups, configured in PostHog’s project settings. Instead of recording a fraction of everything, a trigger group records only sessions that match a condition, a specific URL, event, or feature flag, each group with its own sample rate and minimum duration. Record only sessions where paywall_viewed or support_chat_opened fired (events you defined earlier in this chapter) and you capture exactly the funnels you debug. Because trigger groups live in the dashboard, not in code, you can re-aim them without a deploy: turn on recording for a flow you’re investigating this week, then turn it off when you’re done.
Reading a replay: from ticket to one-line fix
Section titled “Reading a replay: from ticket to one-line fix”This is the skill that justifies the whole primitive: reading a replay player’s panels together to turn an un-reproducible bug into a one-line fix. Here’s the ticket we opened with, refined.
A user reports “the upgrade button does nothing.” No error in Sentry, clean logs, and they can’t reproduce it for you on a call. Here’s the on-call walk.
e.stopPropagation() The console panel was clean and the network panel was clean, and that cleanliness is the diagnosis: there was no error and no failed request, which is exactly why Sentry and your logs missed this. The bug lived entirely in the interaction, in a click event bubbling to a handler it shouldn’t have reached. The fix is a single e.stopPropagation() on the modal content, found by watching a bug nobody could reproduce.
Now suppose the network panel had not been clean. Suppose that at the moment the upgrade bailed, it showed a 500 on a POST /api/checkout. Replay has now told you where and when the failure happened; you copy that request URL and pivot to Sentry and the structured logs from the last chapter, filtered to that endpoint, to find out why the server fell over. Replay localizes the failure in the user’s timeline, the error stack and logs explain the server’s side, and reading them together is the job.
When not to record at all
Section titled “When not to record at all”Recording with masking is the default. A few surfaces are the exception, where the right answer is to capture nothing. Each is a flat rule with a stated reason:
- Internal admin tools: a separate PostHog project, replay off. An operator’s screen is full of customer data, so recording them re-captures customer PII at one remove, masked or not. Keep internal tooling in its own project with replay disabled and the problem never arises.
- Customer-data export and download flows: block the whole flow. An export screen renders a pile of customer data. Put
data-ph-no-captureon the container to keep the entire flow out of the recording. - Payment forms: already handled. The Stripe Elements iframe is isolated by the origin boundary. Class the wrapper
.never-recordfor defense-in-depth. - Auth forms: mask, don’t block. The password is already masked by
maskAllInputs. Leave the username or email field captured as masked text rather than blocking it, so support can confirm which account a session belongs to without reading the address. Blocking the form loses that pivot for no privacy gain.
The meta-rule: record-with-masking is the default, and everything here is a named exception with a reason. If you can’t state the reason, it isn’t an exception, it’s a gap in your masking.
The pre-ship privacy review
Section titled “The pre-ship privacy review”Replay is the easiest PostHog primitive to misconfigure into a GDPR violation, and as the catalog showed, the config rots as the app grows. Perfect setup today drifts into a leak tomorrow. The fix isn’t “be careful”; it’s a repeatable ritual.
Before you ship replay to production, open a real recorded session and scrub through it for visible PII, watching your own app the way an operator would. Anything unmasked that shouldn’t be, a name, an address, a field someone forgot to class, you fix on the spot: add the class, re-verify. Budget thirty minutes before launch, then repeat it quarterly, because new features keep adding new sensitive surfaces. Like the stale-flag audit earlier in this chapter, it’s scheduled hygiene, not one-time setup.
Two residual leak surfaces the scrub has to catch:
Network body capture stays off; that’s the default. Replay captures request metadata (URL, method, status, duration) but not request or response bodies, and bodies are where the PII lives. Metadata alone is enough to debug and to pivot to Sentry. There’s one legitimate reason to turn body capture on: a time-boxed debugging cycle where you need to see a payload. Turn it on, debug, turn it off. The failure mode is enable-and-forget, where you flip it on to chase one bug and now store PII-laden bodies indefinitely.
A replay is operator-side PII access, even when perfectly masked. Masking protects the stored data; the replay is still a recording of a real person’s session. Opening one, or showing it in a screen-share, is access to that user’s session, the operator-PII concern from the security unit. Treat it like any other PII surface: limited access, and a reason to open it.
For GDPR, when a user requests deletion through the flow you built in the security chapter, their replays must go too. Deleting a person in PostHog removes their events and recordings in a single action. Do it server-side through the posthog-node adapter in lib/posthog.ts, or manually with the “Delete person” action in the PostHog app. Wire person-deletion into that deletion flow as one more downstream delete.
Run this checklist before every replay ship and on every quarterly pass, against a real recorded session, not from memory.
maskAllInputs on, verified in a real recording)..sensitive is applied to every PII text field: names, addresses, phone numbers.contenteditable editors are masked by class (the default doesn’t catch them).data-ph-no-capture on the container).External resources
Section titled “External resources”The PostHog docs are the canonical reference for the surfaces this lesson configured, and the rrweb repo is where the “records the DOM, not a video” idea this lesson hangs on actually lives.
The canonical masking-and-blocking reference: every selector, class, and attribute this lesson used.
Sample rates and trigger groups — how to record by value instead of volume.
The correct person-deletion surface for wiring replay into your GDPR deletion flow.
The open-source DOM-recording engine under PostHog's replay — see incremental snapshots in action.