> ## Documentation Index
> Fetch the complete documentation index at: https://hoopdev-docs-control-plane-owns-listeners.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Data Masking

> Rewrite sensitive values in the response, in memory, before they reach the client.

A query runs against real production data and returns real production data. Data Masking sits on the way back: the [Sidecar](/core-concepts/sidecar) decodes the response as it streams, finds sensitive values, and rewrites them before the client ever sees them.

Nothing is copied, staged or transformed at rest. The masking happens in memory, in real time, at the protocol layer — so it works the same for `psql`, for an ORM, for a dashboard and for an agent.

<Warning>
  Masking runs on **responses only**. Requests are never rewritten: changing the statement the upstream executes is a correctness change wearing a privacy label.
</Warning>

<Note>
  **Free tier:** one Data Masking rule and one Guardrail per Sidecar are free, forever. Running more than one rule per feature, or managing rules centrally across Sidecars, requires [Enterprise](https://hoop.dev/start).
</Note>

***

## Configuration

One block. `mask` says what to rewrite, and a `mask` block carrying rules masks — there is no second switch to set.

```yaml config.yaml theme={null}
mask:
  rules:
    - {name: emails, entities: [EMAIL_ADDRESS], strategy: redact}
    - {name: ssn,    entities: [US_SSN], strategy: partial, keep_last: 4}
```

`entities` is **a list**, even for one type, and one rule can name several:

```yaml theme={null}
- {name: national-ids, entities: [US_SSN, BR_CPF], strategy: redact}
```

Detection needs no configuration of its own: **omit `pii` and every supported entity is enabled.** Add the section only to narrow the set, which also makes it exhaustive — a `mask` rule naming a type outside the list is then refused at startup rather than silently masking nothing.

```yaml config.yaml theme={null}
pii:                              # optional
  entities: [EMAIL_ADDRESS, US_SSN, CREDIT_CARD, BR_CPF, IBAN_CODE]
```

The same query now comes back rewritten:

```
     name     |          email           |     ssn     |          iban
--------------+--------------------------+-------------+------------------------
 Ada Lovelace | [REDACTED:EMAIL_ADDRESS] | ***-**-6789 | ******************5432
 Grace Hopper | [REDACTED:EMAIL_ADDRESS] | ***-**-4321 | ******************3000
```

<Warning>
  Breadth has a cost. `US_SSN` carries no checksum, so nine digits in a legal range is a valid one as far as any detector can tell, and a `mask` rule naming it rewrites ordinary numeric columns:

  ```json theme={null}
  {"order_id": 457555462, "customer_id": 123456781}
  ```

  Both are reported as `US_SSN`. Prefer a `columns` rule for a column you can name, and reach for a narrowing `pii.entities` list when a lane's data makes a type genuinely ambiguous.
</Warning>

### Rule fields

| Field       | Meaning                                                                                                                |
| ----------- | ---------------------------------------------------------------------------------------------------------------------- |
| `entities`  | List of entity types to rewrite wherever they appear. **A list**, even for one type. Required unless `columns` is set. |
| `columns`   | Result-set column names to mask outright, compared case-insensitively.                                                 |
| `strategy`  | `redact`, `mask`, `partial` or `hash`. Empty means `redact`.                                                           |
| `keep_last` | Tail length for `partial`. Default `4`.                                                                                |
| `mask_char` | Replacement character for `mask` and `partial`. Default `*`.                                                           |

### Strategies

| Strategy  | `4111111111111111` becomes |
| --------- | -------------------------- |
| `redact`  | `[REDACTED:CREDIT_CARD]`   |
| `mask`    | `****************`         |
| `partial` | `************1111`         |
| `hash`    | `sha256:<first 16 hex>`    |

`hash` is the one worth knowing about: equal inputs give equal outputs, so a masked column still works as a join key. An analyst can group by customer without ever seeing a customer.

***

## Entity rules and column rules

Two ways to name what gets masked, and they fail in opposite directions.

**Entity rules** mask by detection. They work anywhere a value appears, including inside an opaque HTTP body where the protocol names nothing — and they miss whatever the detector does not recognize.

**Column rules** mask by position. They cannot miss, because they never guess — and they only work where the protocol names its values, which means result sets, not payloads.

```
postgres  SELECT ssn FROM customers   →  ***-**-6789   column rule caught it
HTTP      POST {"x":"123-45-6789"}    →  123-45-6789   no detection, no column
```

Use column rules for the columns you know, and entity rules for everything else.

***

## Inheritance: a listener's mask block replaces the defaults

This is the single most common way a working config quietly stops masking:

```yaml config.yaml theme={null}
mask:                                    # top-level defaults
  rules:
    - {name: emails, entities: [EMAIL_ADDRESS], strategy: redact}
    - {name: ssn, entities: [US_SSN], strategy: partial, keep_last: 4}

listeners:
  - name: appdb
    protocol: postgres
    listen: 0.0.0.0:15432
    upstream: appdb:5432
    mask:
      rules:
        - {name: ssn-column, columns: [ssn], strategy: partial, keep_last: 4}
        - {name: emails, entities: [EMAIL_ADDRESS], strategy: redact}   # relisted on purpose
```

A listener's `mask` block **replaces** the top-level list rather than extending it. Adding one column rule drops every inherited entity rule, so list those again alongside it. This is deliberate: a rule owns an entity type, and two concatenated lists would leave two rules competing for one entity.

[`guardrails.rules` behaves the opposite way](/features/guardrails#inheritance-guardrail-rules-concatenate) — it concatenates.

***

## Verify what actually resolved

The admin API reports the resolved state per listener, which is the only place the merge above is visible:

```bash theme={null}
hoop start sidecar --config config.yaml --validate
```

```
config OK: 1 listener(s)
  appdb            postgres  1 rule(s) + masking
```

```bash theme={null}
curl -s localhost:19000/config | python3 -m json.tool
```

<Tip>
  If a value comes back unmasked, the detector probably refused it rather than missed it — obvious test fixtures like `123-45-6789` are deliberately not reported. Add a `columns` rule, or check whether a `pii.entities` list is narrowing detection past the type you expected.
</Tip>

***

<Note>
  Looking for masking on the **Hoop Gateway** instead? That is a different implementation, configured in the web app with DLP providers and per-resource roles. See [Live Data Masking](/learn/features/live-data-masking).
</Note>

## Next

<CardGroup cols={2}>
  <Card title="Guardrails" icon="shield-halved" href="/features/guardrails">
    Control what reaches the resource, not just what comes back.
  </Card>

  <Card title="Config File Reference" icon="file-code" href="/setup/configuration/hoop-sidecar/config-file">
    Every field, the full entity list, and what startup refuses.
  </Card>
</CardGroup>
