Privacy

Does obfuscation break session replay masking?

A session replay recorder is the one third-party script that lives inside your bundle rather than beside it, watches every keystroke, and uploads what it saw to somebody else's servers. What keeps a card number out of that upload is not encryption or access control. It is a handful of property names on a configuration object, read by a recorder that was installed rather than built. We protected a page that configures one, renamed the names a group at a time, and read what came out the other end.

Why this area is different from the others

Most of the guards in this series live on a server. This one does not. The recorder is a script on the page, bundled and shipped with your own application code, which means it is inside the blast radius of whatever you do to that bundle - and the masking rules are configured by your code, in your file, at the exact place a build-time transformation is looking.

It is also the area where a mistake has the longest tail. A leaked API response is a request you can revoke. A replay is a video, sitting in a vendor's retention window, watchable by anyone in the company who has a seat, and it captured what the customer typed rather than what your API returned. Fixing the build fixes the next session; it does nothing about the ones already recorded.

The sample configures the controls every replay SDK ships. All inputs are masked. A selector list blocks the card field outright. A second selector list masks the rendered account summary. The URL query string is stripped, because a password-reset link carries a live credential in it. A payload scrubber runs over the assembled batch on its way out. And one option is pinned to a value identical to the library default on purpose, as a control.

The recorder itself is copied into the measurement directory unprotected. Your bundle is rebuilt when you protect it; the package in node_modules is not, and it keeps reading the property names it has always read. Its defaults are the ones a replay SDK ships when handed an empty configuration: record inputs verbatim, block nothing, mask nothing, record the address bar whole, and upload the batch as assembled.

Protection alone was applied first, on five presets covering both output targets, the gate profile and the compressed profile. All five behaved identically to the unprotected file. Nothing below is caused by protection on its own.

Two rules covered the card field. Each one alone was harmless

The card number input is protected twice, deliberately. It is an input, so the mask-all-inputs rule reaches it. It carries a blocked class, so the selector list reaches it too. That is what defence in depth means in a replay configuration, and it is the arrangement a careful reviewer would ask for.

Renaming the mask-all-inputs option alone: the card field was still protected, because the selector list caught it. The measured line moved from card-number-field="[blocked element]" via=blockSelectors to the same blocked value by the same route, with only an unrelated coupon field losing its mask.

Renaming the selector list alone: the card field was still protected, because mask-all-inputs caught it. The line moved to card-number-field="****************" via=maskAllInputs. A different mechanism, the same safe outcome.

Each of those is a value diff with no behaviour change - which is precisely the finding a reviewer closes as cosmetic, and precisely the signature of this failure shape.

Renaming both, which is what one pattern over one options object actually does, produced card-number-field="4242424242424242" via=recorded-verbatim and card-readable-in-replay=true. The two layers were genuinely independent against every failure mode except the one that mattered: they are properties of the same object in the same file, so a single mechanical transformation reached both at once.

The lesson is about placement rather than count. A second layer only helps if it lives somewhere the transformation cannot reach. Two rules in the same options literal are one layer wearing two hats.

The mask the recorder will not let you switch off

Every replay SDK masks password inputs unconditionally. It is the one control that is not the caller's to disable, it is advertised as always-on, and it is the reason teams feel safe letting a recorder near a login form.

It is keyed on the field's own type attribute - the recorder compares that value against the literal string "password". That is data on the node, not a name in the configuration, and it lives in your markup and your component code, which is inside the bundle.

Renaming that field name alone was survivable: the password field was still masked, because mask-all-inputs caught it. The measured line changed only in its reported route, from via=password-type-always-masked to via=maskAllInputs.

Renaming that field name together with mask-all-inputs produced password-field="hunter2hunter2" via=recorded-verbatim. The password the customer typed, in clear text, in a replay. The un-disableable guard was switched off by moving the data it keys on, and the only remaining control was the one the caller can turn off - and had just lost.

This is the shape that hides best, because the reasoning that would catch it is wrong in a subtle way. "The recorder always masks passwords" is true. It is true of the recorder. It is not true of a node whose type field is no longer called type.

The rest of the area, briefly

Renaming the text-mask selector list took the account summary from [masked text] to [email protected] card ending 4242, rendered on the page and recorded as rendered. Selector lists are rules expressed as data: the strings keep matching whatever they always matched, and the matching stops because the option carrying them is no longer read.

Renaming the URL option took the recorded address from /reset?[query removed] to /reset?token=rt_9f31c0b8ab&uid=8814. That token is a live credential for the length of its window, and it is now in a replay that support staff can open.

Renaming the payload scrubber substituted the library's identity function, and the assembled batch went out with the card number in it: card-on-the-wire=true. The reported scrubber moved from caller-supplied to library-builtin, which is the only line naming the substitution. That is the same downgrade shape this series has now measured in fifteen libraries across seven passes.

Two arms measured no change at all, and both are options pinned to a value identical to the library default. That is the expected result and it is why they were included: an arm that cannot move is not evidence that the others cannot.

What the monitoring would have said

Nothing. Every arm in this area leaves the recorder working, the session count unchanged, the batch sizes normal and the vendor dashboard green. The recording is not missing, corrupt or truncated; it is more complete than it was supposed to be.

The masking telemetry is worse than silent in one arm. Renaming the field the recorder uses to report which rule fired left every value correctly masked while the reported route read undefined - a report you cannot trust, in the safe direction this time. Elsewhere in this pass the same shape reported the safe answer while the system was unsafe. Once a result field's name has moved, a conclusion drawn from it is not evidence in either direction.

The practical consequence is that a replay privacy check has to be done by opening a replay. Record a session against a build produced by your real pipeline, on a form with a card field and a password field, and look at it. Reading the configuration proves what the source intends, which is a different claim from what the deployed bundle does.

What to do about it

Scope the renaming. The option is RenameMembers and MemberRegexp is what confines it; the exclusion should cover the recorder's option names and, in this area specifically, the DOM field names your components set - type, id and the class names your selector lists refer to.

Do not rely on two rules in the same options object as two layers. If a field genuinely must never be recorded, keep it out of the recorded DOM: render it in an iframe the recorder does not instrument, or never let the value reach a node at all. That is a placement the transformation cannot reach.

Treat the vendor's always-on guarantees as conditional on your markup. They are implemented against attribute values in your document, and a build step that rewrites property names in your components is exactly the thing that can move them.

If your obligations here are regulatory rather than contractual, the compliance pages linked below cover which of these controls the relevant frameworks expect you to evidence, and the evidence is a recorded session, not a configuration file.

Frequently asked questions

Does protecting my JavaScript break replay masking on its own?

Not in this measurement. The page was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result in this article required member renaming pointed at property names.

We mask the card field twice. Does that not cover us?

It covered every failure mode except this one. Each rule alone was measured harmless: whichever survived caught the field. Both are properties of the same options object in the same file, so one pattern reached both and the raw card number was recorded verbatim.

Is the password mask not always on?

It is always on in the recorder. It is keyed on the input's own type attribute, which lives in your markup. Renaming that field together with the mask-all-inputs option recorded the typed password in clear text, because the un-disableable rule had nothing left to key on.

Would our dashboards show anything?

No. Every arm leaves the recorder working, the session count unchanged and the batch sizes normal. The recording is not missing or corrupt; it contains more than it should. One arm also made the masking telemetry report undefined while the masking itself was still correct.

Why is this worse than the server-side cases?

Because a replay is a video in a vendor's retention window, watchable by anyone with a seat, and it captured what the customer typed rather than what your API returned. Fixing the build fixes the next session and does nothing about the ones already recorded.

What about the URL query string?

It was its own arm. Renaming the option that strips it recorded a password-reset link complete with its token, which is a live credential for the length of its window, into a replay that support staff can open.

How do we actually check this?

Record a session against a build from your real pipeline, on a form with a card field and a password field, and watch it back. Reading the configuration proves what the source intends, which is a different claim from what the deployed bundle does.

Related reading