August 28, 2026
Privacy at Capture
The data-privacy attribute lets a page tell any capture SDK what to mask, block or unmask.
Rogier Trimpe
@roggernautPrivacy rules for session recording usually live in SDK configuration or vendor-specific CSS classes. That makes a change of recorder a privacy migration.
Privacy at Capture is now open for review as a pull request against rrweb. It proposes a DOM attribute that the page owns, backed by a versioned policy that other web and mobile capture SDKs can implement.
A shared capture standard lets a privacy decision made once in application markup be enforced by every recorder that implements it. Protection then survives migrations and SDK rewrites, while component authors can ship privacy intent with the component itself.
<div data-privacy="mask">Account balance: €12,480.30</div>rrweb keeps the element and replaces its content with stars while serialising the page. The recording backend never receives the account balance.
One attribute
The proposal defines three values:
<section data-privacy="mask">Keep the layout and mask the content.</section>
<section data-privacy="block">
Replace this subtree with a same-sized placeholder.
</section>
<nav data-privacy="unmask">This navigation is safe to record.</nav>maskkeeps the element's shape and obfuscates its content.blockreplaces the whole subtree with a placeholder of the same size. Descendants cannot unmask it.unmaskexposes content inside a masked region when the policy permits it.
Password and other protected inputs stay masked. unmask only escapes text and attribute masking where the policy permits it: it cannot expose a protected input, open a blocked subtree, undo URL sanitisation or restore media removed by the strict preset. When conflicting values match the same element, the strictest wins: the ladder runs unmask, mask, block.
A value rrweb does not recognise is treated as mask. A typo in the attribute therefore over-protects, and never exposes content by accident.
That guarantee is why the standard is one attribute rather than a family of them. With a single data-privacy, there is exactly one name to get right, and every possible mistake is a value mistake -- and value mistakes mask. The selector that enforces it is plain CSS: anything carrying the attribute is masked unless its value is exactly a recognised reveal. There is no code path that can forget to check.
The alternative we considered, and that other tools ship, is verb-named attributes: data-privacy-mask, data-privacy-block, and so on, with the value carrying a parameter. It reads well, but it moves the typo surface from the value to the attribute name, and CSS cannot fail-close a name: data-privacy-msak matches no selector and records the element in full, silently. A masking convention whose misspellings expose content needs a scanner to guard its own grammar. One whose misspellings over-mask needs nothing.
The single attribute also leaves room to grow without new names. The value is specified as a token list, like class or rel: unmask and block are always bare, and future masking modes ride alongside the verb -- data-privacy="mask fixed-length" to also hide a value's length, data-privacy="mask lipsum" for readable placeholder text, or data-privacy="mask encrypt" to carry a ciphertext only the page owner can open (see Encryption below). A recorder that predates a mode sees an unrecognised value and masks, so tomorrow's annotations degrade safely on today's recorders. The data-privacy-* attribute namespace is reserved by the standard for later extensions, such as data classification labels.
We chose data-privacy over a bare privacy attribute because HTML defines data-* for application-owned annotations. It works on any element, is available through element.dataset.privacy and avoids a collision if HTML gains a native privacy attribute later. Leaving out a recorder prefix lets the annotation survive a change of tool. A new component should use data-privacy from the start: it states the component's privacy requirement without tying it to a recorder.
Policy
Markup covers components a team can edit. A policy sets the defaults for the rest of the page: generated interfaces, third-party widgets, and everything nobody tagged.
import { record } from 'rrweb';
record({
emit(event) {
// The event is already masked here.
},
privacyPolicy: { version: 1, preset: 'balanced' },
});balanced masks every form input and sensitive descriptive attributes such as title, placeholder and aria-label. strict also masks page text, removes media sources and disables canvas recording. minimal leaves masking to the classic recording options you configure yourself, and records exactly as rrweb does today with one deliberate exception: password and payment fields always come out masked, because those are too sensitive to be configurable.
minimal is a permanent tier. It is not a staging step on the way to a managed preset, and teams that have tuned their own masking can stay on it indefinitely. It carries one floor that cannot be configured away: password and payment fields always record as stars, because those values are too sensitive to leave to configuration. That floor applies in every preset, including recordings started without a policy. Existing recordings therefore do not change: the default is still the minimal path and existing masking options keep working. The protected set is password and hidden inputs plus fields whose autocomplete value is cc-*, current-password, new-password or one-time-code, and those stay masked even where your own options would expose them.
A policy can also carry selector rules for things markup cannot reach, such as blocking a third-party support widget with rules: [{ target: { type: 'selector', selector: '.support-widget' }, action: 'block' }]. Rules are the exception path; the preset is the policy.
rrweb compiles the policy into the selectors, flags and sets already used by its serializer. The initial snapshot and later DOM mutations use the same masking decisions. There is no second rule-engine walk over every node.
Prior art
Privacy at Capture draws on MIT-licensed work in rrweb, PostHog, Highlight, Sentry, Amplitude, Mixpanel, Datadog and New Relic.
Those projects already use the main techniques in this proposal: broad masking with explicit escapes, selector merging, permanent protection for sensitive inputs, centralised attribute decisions, dimension-preserving image blocks and canvas-region masking.
Privacy at Capture packages those techniques behind one attribute, policy schema and precedence model. That contract is the part under review.
Three optional features remain experimental:
| Feature | Behaviour | Open question |
|---|---|---|
| Text detectors | Mask a whole text node after an email, phone, Luhn-valid card, SSN-like or IPv4 match | The fixed pattern set and live mutation path need production evidence |
| URL sanitisation | Remove credentials and mask selected query parameters before recording href, src and related attributes |
The default parameter list and replacement format need review |
| Unmask probe | Skip ancestor checks when a snapshot contains no possible unmask match | The optimisation needs evaluation around shadow DOM boundaries |
Keeping these features separate lets maintainers review the structural policy without taking on unproven detection and optimisation work.
Migration
Pages already contain privacy classes from their current recorder. Under the balanced and strict presets, rrweb always honours data-privacy and its own .rr-* classes.
Conventions belonging to other tools are opt-in, and selectable per vendor. vendorCompat: ['posthog'] recognises only PostHog's mask and block tokens; vendorCompat: true recognises the full registry of twenty-five tools. Each recognised token maps to the closest treatment rrweb's own verbs express, verified against the vendor's SDK or documentation, and never a less protective one. Escape tokens are the deliberate exception: no foreign unmask or allow marker is ever honoured, under any setting -- not even the previous vendor's own. Recognition can only add masking, so a stale class can over-protect a page but never expose one.
The default is off because recognition changes recording behaviour based on markup the embedding application may not control. A class that arrived with a third-party widget should not silently change what rrweb records, so turning it on is an explicit decision.
A migration, then, has three steps. Start recording with vendorCompat set to your previous vendor's id, so every annotation that protected the page yesterday protects it today; if the old markup used the vendor's unmask escape, re-declare those few elements explicitly with data-privacy="unmask" or an unmask rule in the policy -- reveal decisions belong in your own configuration, not a compat flag. Convert annotations to data-privacy as you touch each component. When the old classes are gone, remove the flag and the policy stands on the standard alone.
Errors
Runtime errors and invalid policy configuration need different handling.
During capture, each failure has a defined result. A custom masking function that throws or returns an invalid type falls back to the built-in star mask. If an unmask selector throws, it creates no match, so inherited masking stays in place. When mask and unmask both match, mask wins. An unrecognised data-privacy value is read as mask. The optional URL sanitiser drops an attribute when it cannot parse the value.
Selector syntax is checked once, when recording starts. rrweb drops a malformed selector and reports a warning. Other selectors continue to work. If the bad selector was supposed to mask content, that content is no longer protected by the rule. Applications should treat the warning as a configuration error.
What comes later
Everything above is in the pull request under review. Canvas masking, text detectors and encryption are designed and benchmarked but belong to later releases. They are described here so the contract can be read as a whole; none of them changes what a recorder does with data-privacy today.
Canvas and mobile
A DOM attribute cannot hide pixels already painted onto a canvas. The proposal adds a region provider for editors, maps and design tools. An application returns rectangles in CSS pixels for each sampled frame. rrweb maps them to backing-store coordinates and masks those regions before encoding the frame.
If the application cannot determine safe regions, it can skip the frame. Provider errors and invalid rectangles also skip capture. Strict mode disables canvas recording entirely.
The same terms can be used by Swift and Kotlin SDKs. Native implementations can apply mask, block and unmask during view traversal, while drawing surfaces expose protected regions through platform APIs. The current pull request covers the web recorder. A cross-platform conformance suite should follow once the DOM contract settles.
Detectors
The optional detector scans text content only: text nodes in the initial snapshot and live text mutations after it. A match masks the whole text node. It does not scan attributes or accept custom regular expressions.
Typed input is never scanned. While the detector is loaded, rrweb occludes every input value to its length from the first event. Waiting for a complete pattern would record the characters typed before an email address or card number becomes recognisable. A clean scan also says nothing about passport numbers, dates of birth, account IDs or sensitive free text, so a scanned value that looks safe is not evidence that it is.
Use annotations and policy rules for known data. Detectors cover text that appears somewhere the application did not expect; they are not enough on their own, and presets, annotations and selector rules remain the dependable controls for known sensitive content.
Encryption
Masking destroys the value. Some teams need the original later: a support engineer reading the order number a customer typed, a fraud team checking an account id. Today that means leaving the field unmasked, so the recording vendor can read it too.
The proposal adds a mode token on mask:
<span data-privacy="mask encrypt">Order 48213-FR</span>Every recorder that exists today treats this as mask: the content becomes stars and nothing readable leaves the browser. A recorder that implements encrypt also keeps a ciphertext of the value under a key the backend does not hold. An unknown token masks, so the fallback needs no design.
When recording starts, rrweb generates a random session key in the browser and wraps it once to a public key from the policy. Each mask encrypt field is encrypted inside the serializer, on the same synchronous path that masks it, with XChaCha20-Poly1305 from an audited pure-JavaScript cipher of about 5 kB. The cipher must be synchronous: the serializer, the mutation flush and emit share one call stack, WebCrypto returns Promises, and the guarantee that plaintext never exists in a serialized event would break at the first await.
record({
emit(event) {},
privacyPolicy: {
version: 1,
preset: 'balanced',
encryption: {
keyId: 'k1',
publicKey: '-----BEGIN PUBLIC KEY-----…',
},
},
});The events do not change. The field is recorded as stars, exactly as under mask, so layout, older players and existing consumers keep working. The ciphertext travels in a separate stream beside the events, keyed to session and node, with the wrapped session key. The backend, rrweb Cloud included, stores a stream it cannot read: the private key never leaves the page owner.
Decryption happens where the private key is. A small tool takes an exported recording and the key, merges the two streams into plaintext events, and hands them to rrweb-player. Each ciphertext names its key id, so the public key can rotate without losing old recordings. A lost private key leaves those fields as stars.
Measured inside the real serializer, encryption adds about 5 microseconds per field on a desktop CPU and 25 to 36 microseconds under a 4x to 6x CPU throttle; 10,000 encrypted fields turn a 69 ms initial snapshot into 129 ms. The snapshot is where the budget goes, so the recorder caps encrypted fields per snapshot and encrypted text mutations per second. Past either cap, or when the key is not ready, the wrap failed or the cipher threw, the field is stars and the session carries a marker saying why. A slow device and a broken cipher get the same treatment: the content is not transmitted.
The limits. The backend cannot search, index or process encrypted fields. Password and payment fields stay stars under every setting, including mask encrypt, because card data should not be recorded in any form. Length leaks as it does under mask; mask encrypt fixed-length hides it. And the accurate claim is "encrypted to a key only you hold". It becomes end-to-end only when the viewer's JavaScript is served by someone other than the vendor.
Encryption is outside the pull request under review. The design, benchmarks and open questions are published with the proposal, and the capture-side hooks it needs (resolveTextValue, finalizeAttribute, maskInput) are already in the reviewed code.
What integrators should know
A few consequences reach the code that reads or replays the events.
- A serialised attribute value can now be
null, which means the attribute was dropped, so event consumers should handle a null alongside a string. - A masked image, video or other media source is replaced by an inline SVG data URI that keeps the original dimensions, so consumers should expect a
data:URI where a remote URL used to be. - Hidden inputs and payment fields record as stars on every tier, including
minimaland recordings started without a policy. - The
strictpreset disables canvas recording, so no canvas mutation events arrive for a session recorded under it. - Loading the detectors plugin occludes every input value to its length for the whole session, whether or not a pattern ever matches.
- Every ambiguous case resolves to masking, including unrecognised attribute values, failed selectors and masking callbacks that throw.
- Under the proposed
encryptmode, events still carry stars; ciphertext arrives in a separate stream that consumers can ignore, and a session whose encryption was suspended carries anencryptionDisabledmarker.
Credits
This proposal reuses and adapts MIT-licensed recorder work. The source licences and copyright notices remain with that code. The names below come from the relevant files and commit history.
Anna Garcia built PostHog's canvas-region masking. PostHog's attribute pipeline was directed and hardened by Manoel Aranda Neto, Arno Hillen and the replay team. Datadog's privacy levels include work by John Gracey, Benoît Zugmeyer, Congyao Zheng and Seth Fowler.
Jakub Grzegorzewski wrote Mixpanel's masking layer. At Amplitude, Joseph Jin built attribute masking and Lew Gordon built URL-aware privacy configuration. Highlight's text obfuscation and detectors include work by Jay Khatri, Vadim Korolik and Spencer Amarantides. New Relic's protected-input and configuration work includes contributions from Jordan Porter and ptang-nr.
Commit history is an imperfect record. Please send corrections or missing attribution to the pull request.
Review
The Privacy at Capture pull request contains the core implementation, provenance notes, performance tests and a list of experimental boundaries. The three experimental features are stacked on it as separate pull requests: text detectors, URL sanitisation and the unmask probe. The code is MIT licensed with rrweb. It is not released yet; applications evaluating it should pin the pull-request branch until it is merged and published.
Convergence gives capture SDKs a common safety floor for precedence, protected inputs and failure handling. Vendors can keep their own serializers while testing them against the same privacy cases.
We need feedback from people who own site markup, people who maintain capture SDKs and privacy teams that review these systems. In particular:
- Do
mask,blockandunmaskcover the decisions a component needs to express? - Can another serializer implement the precedence rules without adopting rrweb internals?
- Which failure case or immutable protection is missing from the tests?
If the contract works for your SDK, say so in the pull request review. Bring the first case it cannot express.
Tested in rrweb Cloud
rrweb Cloud is the live test bed for these settings. Running the policy against real applications gives us evidence about defaults, browser edge cases, performance and downstream event compatibility. We use those findings to improve rrweb's core implementation and public tests before asking other capture SDKs to adopt the contract.
Cloud provides the production environment needed to harden the recorder. The privacy contract and the resulting core improvements remain open to every downstream project. This is the best way for us to improve the quality of the shared library while the standard is still taking shape.
Written by Rogier Trimpe
@roggernaut on GitHubMore in Updates
Get started
Ready to try rrweb Cloud?
Free tier, no credit card required. Start recording sessions in minutes.
Sign up free