# Data protection and scrubbing

> What the SDK and the node remove or mask, what cannot be guaranteed, and how to check it safely.

Error reports can carry things you never meant to collect: a password in a form value, a token in a URL, an email in a message. Belvador filters at two independent layers — in your app and on your node — and keeps the data in your own Hetzner project. Filtering lowers the risk; it cannot remove it.

## Where your data goes

| Place | Holds | Who can read it |
|---|---|---|
| Your app | The event before it is sent; scrubbed by the SDK first | Your code |
| Belvador's edge (`ingest.belvador.dev`) | The event in transit. If your node is unreachable, a copy sealed with your node's key is buffered for up to 14 days | Only your node can open sealed data |
| Belvador's edge (`edge.belvador.dev`) | Nothing stored. When someone in your workspace opens errors, traces or logs, the edge passes them from your node to their browser in memory, after checking a short-lived ticket | No one — nothing is kept |
| **Your node**, in your Hetzner project | Events, issues, attachments, source maps — scrubbed again before storing | People in your workspace, through the app |
| Belvador's control plane | Account, workspace, project and configuration metadata, counts | Belvador, for running the service |
| Belvador's heartbeat service (`ping.belvador.dev`) | For [ping URL monitors](https://belvador.dev/en/docs/monitors/heartbeat) only: a fingerprint of each ping URL, which signal arrived and when, deadlines and incident state — never request bodies, headers or IP addresses (output a job POSTs goes to your node, like an event) | Belvador, so missing pings alert even when your node is down |
| Your own webhook | If you add one: the alert's metadata (names, codes, times, a dashboard link), signed — see [Custom webhooks](https://belvador.dev/en/docs/alerts/webhooks) | Whoever runs that endpoint |
| Backups in Cloudflare R2 | Encrypted copies of your node's data | Whoever holds the backup key — see [Backups](https://belvador.dev/en/docs/node/backups) |

Events are kept for the retention period of your plan (30 days by default); daily summaries for 13 months.

## Layer 1 — the SDK, before anything leaves your app

Belvador's SDKs (`@belvador/*`) always:

- **remove** the headers `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie`, `X-Forwarded-For`, `X-Real-IP`, `X-Client-IP`, `CF-Connecting-IP`, `True-Client-IP` and `Forwarded`;
- **remove** request bodies, cookies and environment data from request info;
- **keep only `user.id`** from user data (up to 128 characters) — emails, names and IP addresses are dropped;
- **mask** as `[Filtered]` any value under a sensitive name — names containing `password`, `passwd`, `pwd`, `secret`, `api_key`/`apikey`, `access_token`, `auth`, `credentials`, `credit_card`/`card_number`, `cvv`, `private_key`, `session`, `cookie`, `token`, `bearer`, `ssn` or `dob` — in nested data, tags, contexts, extra data, breadcrumbs and query strings;
- **mask in text**: credentials in URLs, `name=value` pairs and JSON strings with sensitive names, bearer tokens and other `Authorization: <scheme> <value>` text, JWTs, keys named by their provider prefix (Stripe, GitHub, GitLab, OpenAI, Anthropic, AWS access key IDs, Slack, Google API keys, Stripe webhook secrets, npm, Supabase secret keys and Belvador ingest keys; all but Stripe since SDK 0.5.2), the secret path of Slack and Discord webhook URLs and Telegram bot URLs, card numbers that pass the Luhn check, the local part of email addresses, also when percent-encoded as `jane%40example.com` (the domain stays), and the last part of public IPv4 addresses.

Data deeper than 10 levels or beyond 1,000 keys is not walked. Your `beforeSend` hook runs **before** this scrubbing.

The [Node.js](https://belvador.dev/en/docs/sdk/node) and [Python](https://belvador.dev/en/docs/sdk/python) setups add a `beforeSend` hook that applies the same key list and similar text rules.

## Layer 2 — your node, before storing

Your node runs its own scrubber, written separately from the SDK so one bug cannot disable both:

- the same list of sensitive names, masked as `[Filtered]`;
- patterns for bearer tokens and other `Authorization: <scheme> <value>` text, Stripe secret keys, JWTs, card numbers and email addresses (also percent-encoded, `%40`);
- from node 0.1.31, the same provider-named keys and webhook URL paths as the SDKs;
- the user name and password in URLs such as `https://user:pass@db.example.com` removed;
- IP addresses masked: the last part of IPv4 is zeroed, IPv6 keeps only its first 48 bits. In free text, such as an error message, public IPv4 addresses are masked the same way; private, loopback, CGNAT and link-local addresses (`10.0.3.17`, `127.0.0.1`) stay readable because they usually name your own servers;
- user IDs replaced by a salted hash;
- `Authorization`, `Cookie`, `Set-Cookie` and `Proxy-Authorization` headers removed.

It covers exception values, stack frame variables and source context, breadcrumbs, tags, contexts, extra data, messages, user fields and request data.

### Built-in detectors

**Project → Privacy → Built-in detectors** lists these groups; all are on by default. Turning one off means data of that kind sent by an SDK is stored as-is and shown to everyone in the workspace. Only owners and admins can change it; turning one off needs your authenticator code, owners get an email, and every change is recorded in the audit log.

## Layer 3 — project rules (the Privacy page)

**Project → Privacy** decides what your node never stores for that project. Your node applies it before anything is written — to errors, OpenTelemetry spans and logs — from node agent 0.1.36. A saved change applies to data that arrives afterwards; data already stored is not rewritten.

The order never changes:

1. **Allowlist** (Strict mode only). Strict keeps the minimum an error, span or log needs — level, error type, stack frames without variables, release, environment, trace ids — plus the fields you list. Standard keeps everything the next steps do not remove.
2. **Always removed**, whatever the project says (cannot be turned off or allowlisted): cookies, auth headers, one-time and verification codes, passwords, secrets and private keys, request, response and message bodies, card numbers (Luhn-checked) and CVV, stack frame variables.
3. **Your rules**, in order, up to 100.
4. **Built-in detectors** (layer 2).

A rule is a selector, a type and a method:

| Part | Values |
|---|---|
| Selector | A lower-case path such as `extra.phone`, `request.headers.x-api-key` or `contexts.*.token`; `*` is one level, `**` any depth. Typed selectors: `$string` (any text value), `$request`, `$user`, `$frame.vars`, `$log.attributes`, `$span.attributes`. Combine with `&&`, `\|\|` and `!`. A log's own text is `message`. |
| Type | `anything` (the whole value), `pattern` (a regular expression), `creditcard`, `ip`, `email`, `phone_e164` (international numbers such as `+84901234567`) |
| Method | `remove`, `replace` (a fixed text of at most 32 characters), `mask` (keeps the last 2 characters), `hash` (a keyed per-project hash, 16 hex characters: the same input gives the same output, so you can still count and search) |

Patterns must be safe to run on every event: back-references, look-arounds, nested repeats such as `(a+)+` and patterns longer than 256 characters are refused when you save. Each text value is checked up to 16 KiB; if a payload takes longer than its time budget, the remaining fields a rule would touch are removed, and the page counts it.

**Test rules** runs the rules in your browser on a sample you paste or a synthetic one — the sample is never sent. It shows the result after the always-removed fields and your rules (built-in detectors run after that, on your node), and which rule matched which path, never the matched value. Hashes show as `[hash]` because only your node holds the key.

**Saving** creates a new version; **Version history** shows who changed what and when, with a diff. A change that keeps more data — deleting or turning off a rule, switching back to Standard, adding to the allowlist, turning a built-in detector off — needs your authenticator code; owners get an email and the change is recorded in the audit log. Owners and admins can edit; other members see the page read-only. Over the API and MCP, `privacy.get` reads the policy and `privacy.set` always waits for a person's approval.

**Node status.** Your node reports which version it applies. If it has no valid policy — for example its configuration could not be verified — it stores only the strict minimum and counts those payloads; the page shows the count.

### The same rules in your app (optional)

From SDK 0.6.0, your app can apply the project's rules before anything leaves it, with the same engine your node runs. **Copy as code** on the Privacy page copies the saved policy as JSON; save it in your app and pass it to `init`:

```ts
import { init, privacyPolicy } from "@belvador/browser"; // every @belvador/* package exports it
import policy from "./belvador-privacy.json";

init({ dsn: "https://<public-key>@ingest.belvador.dev/<project-id>", privacyPolicy: privacyPolicy(policy) });
```

Your node still applies the project's current policy, so an app with an older copy is never less protected — copy again after a change to also filter in the app. A JSON that is not a valid policy is not applied and reports [`BLV_PRIVACY_POLICY_INVALID`](https://belvador.dev/en/docs/sdk/verify#blv-privacy-policy-invalid). The rule engine is bundled only into apps that import `privacyPolicy`.

**See exactly what would be sent.** `preview: true` in `init` (or `BELVADOR_PREVIEW=1` on a server) prints each envelope — after your `beforeSend`, these rules and Belvador's scrubbing — to the console (stderr on servers) and sends nothing. Use it while developing; turn it off to send.

### Monitors and traces

A check-in carries the monitor slug, status, duration, environment, release, attempt numbers, an optional short reason code and the trace id — no message text, request data or user data. It is stored on your node. Belvador's own database keeps only each monitor's settings and state (healthy, failing, since when) and its incidents, so pages and alerts work while your node is offline. Trace ids are random identifiers; they are stored with errors and runs on your node and are never put in alert messages. OpenTelemetry spans and logs you send are also stored only on your node and filtered there first — see [OpenTelemetry privacy](https://belvador.dev/en/docs/otel/privacy).

## What filtering cannot guarantee

- **Unusual names.** A secret stored under a name like `x` or `value` is not recognised.
- **Free text.** A message such as `` `Login failed for jane@example.com with Hunter2!` `` has the email partly masked, but the password is just text. Names, phone numbers and street addresses in text are not recognised, and neither are IPv6 addresses written inside a sentence.
- **Broad names.** Matching is by "contains", so harmless names such as `author`, `session_count` or `token_count` are masked too. Rename the field if you need its value.
- **Attachments** are size-checked, not content-scrubbed. Only enable `attachments` for files you know are safe.
- **Your own metadata.** `release`, `environment`, fingerprints and tag *names* are sent as written.
- **Other SDKs.** If you keep an SDK other than Belvador's, its defaults apply until your node's scrubber runs.

## Safe habits

- Never pass secrets, tokens or full user objects to `captureException` context, tags or `setContext`.
- Use `setUser({ id })` with an internal ID, not an email.
- Test scrubbing with **fake** values, such as `password=not-a-real-secret` — never with real credentials.
- If a real secret ever reaches Belvador, **rotate that secret** first; deleting the event does not undo the exposure.

## Legal

The [Security](https://belvador.dev/en/security) page describes Belvador's controls and how to report a vulnerability. The [Data processing](https://belvador.dev/en/data-processing) page explains roles under data-protection law.
