---
title: "Exfiltration guard and canaries"
description: "Stop a stolen secret or a bulk copy of personal data leaving your app, and plant records and keys that raise an alarm when an intruder touches them."
section: "interfaces"
status: "Building for 0.4 · ships as a beta API"
spec: "exfiltration-guard"
canonical: "https://mainsdk.dev/docs/interfaces/canary"
updated: "2026-10-28"
---

# Exfiltration guard and canaries

> Stop a stolen secret or a bulk copy of personal data leaving your app, and plant records and keys that raise an alarm when an intruder touches them.

Status: Building for 0.4 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/canary · Updated: 2026-10-28

`canary` stops a stolen secret or a bulk copy of personal data leaving your app, and plants records and keys that raise an alarm when an intruder touches them.

The guard is off until `main.yaml` has an `exfiltration` or `canaries` section. Once on, it adds one in-memory scan to outbound calls and responses.

## At a glance

- Status: Building for 0.4 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/exfiltration-guard.md
- Category: Security and privacy

Calls:

- `canary.plant(model, { count? })`
- `canary.plantAll()`
- `canary.status()`

In main.yaml:

```yaml
exfiltration:
  mode: enforce
  outbound:
    secrets: block
    receivers: { api.partner-crm.com: [personal] }
  responses:
    pan: block
    bulk: { personal: 500 }
    actor-window: { window: 1h, personal: 5000 }
canaries:
  records: { users: 2 }
  keys: true
  on-touch: alert
```

Guarantees, whatever the driver:

- A resolved secret leaves only for its own provider, in any common encoding, and the value is never logged
- A response carrying a secret or a card number becomes a 500 with no value in it
- A canary touch raises one critical security event and the reader is not told
- Never in the request path beyond one in-memory scan; fail static


## Drivers

Pick one in main.yaml with `canary.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in (an Aho-Corasick matcher on the standard library) | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-EXFIL-0006` | check | An `exfiltration` value is invalid: a receiver not in `network.allow` or any driver's hosts, an unknown label, a bulk limit below 1, or an unknown key |
| `MAIN-CANARY-0003` | check | `canaries` is set but nothing is planted in that env, or a model named under `records` doesn't exist |
| `MAIN-EXFIL-0001` | runtime |  |
| `MAIN-EXFIL-0002` | runtime |  |
| `MAIN-EXFIL-0003` | runtime |  |
| `MAIN-EXFIL-0004` | runtime |  |
| `MAIN-EXFIL-0005` | runtime |  |
| `MAIN-CANARY-0001` | runtime |  |
| `MAIN-CANARY-0002` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| exfiltration and canaries sections in main.yaml with their check rules | 1 | 0.4 | `exfiltration-guard#schema` |
| Known-value matcher with chunked scanning | 1 | 0.4 | `exfiltration-guard#matcher` |
| Outbound secret and labelled-value check | 1 | 0.4 | `exfiltration-guard#outbound` |
| Response check | 1 | 0.4 | `exfiltration-guard#responses` |
| mainsdk canary plant and status | 1 | 0.4 | `exfiltration-guard#canary-plant` |
| canary.touched event | 1 | 0.4 | `exfiltration-guard#canary-touch` |
| Canary API key and secret | 1 | 0.4 | `exfiltration-guard#canary-keys` |
| observe | 1 | 0.4 | `exfiltration-guard#modes` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
exfiltration:
  mode: enforce
  outbound:
    secrets: block
    receivers: { api.partner-crm.com: [personal] }
  responses:
    pan: block
    bulk: { personal: 500 }
    actor-window: { window: 1h, personal: 5000 }
canaries:
  records: { users: 2 }
  keys: true
  on-touch: alert
```

```ts title="app/example.ts"
import { canary } from "mainsdk/canary";

// plant two canary users in each region's canary tenant, once; counts only, never ids
await canary.plant("users", { count: 2 });
const { records } = await canary.status();   // { users: 4 }
```

## Configuration

The Quick start block above shows every section. Unknown keys and invalid values fail `mainsdk check` with `MAIN-EXFIL-0006`.

| Key | What it does |
| --- | --- |
| `exfiltration.mode` | `observe` logs a would-block, `warn` logs and writes a security event, `enforce` blocks. Defaults to `enforce` |
| `exfiltration.outbound.secrets` | `block` (default) or `allow`: a resolved secret sent to a host that is not its provider |
| `exfiltration.outbound.labels` | The labels checked on outbound calls: `personal`, `sensitive`, `child`, `phi`. All four by default |
| `exfiltration.outbound.receivers` | Hosts and the labels each may receive |
| `exfiltration.outbound.secret-hosts` | More hosts a named secret may go to |
| `exfiltration.responses.secrets` | `block` (default) or `allow` for a resolved secret in a response |
| `exfiltration.responses.pan` | `block` (default) or `allow` for a card number in a response |
| `exfiltration.responses.bulk` | Most records per label one response may carry |
| `exfiltration.responses.actor-window` | `window` (one hour by default) and the most records per label one actor may read in it |
| `exfiltration.scan.max` | The largest body that is scanned. 1MB by default |
| `canaries.records` | Canary records per model, such as `{ users: 2 }` |
| `canaries.keys` | `true` also plants a decoy API key and secret. Off by default |
| `canaries.on-touch` | `alert` (default) or `block` when a canary is touched |

## What you get

| Call | What it does |
| --- | --- |
| `canary.plant(model, { count? })` | Plants canary records of one model in each region's canary tenant, up to `count` (default from `canaries.records`). Planting twice adds nothing. Returns how many it added, never ids. |
| `canary.plantAll()` | Plants every model under `canaries.records`, and with `keys: true` the canary API key and the `MAINSDK_CANARY_TOKEN` secret. |
| `canary.status()` | Counts only: canary records per model across regions, whether keys are planted, and when one was last touched. |

## What it always does

- A resolved secret leaves only for its own provider, in any common encoding, and the value is never logged
- A response carrying a secret or a card number becomes a 500 with no value in it
- A canary touch raises one critical security event and the reader is not told
- Never in the request path beyond one in-memory scan; fail static

## What the guard checks

| Where | What it stops | Code |
| --- | --- | --- |
| Outbound call | A resolved secret in the URL, headers or body (raw, base64, base64url or URL-encoded, also split across chunks) sent to a host that is not that secret's own provider. List more hosts under `outbound.secret-hosts` | `MAIN-EXFIL-0001` |
| Outbound call | Labelled values read in this request (`personal`, `sensitive`, `child`, `phi`) sent to a host not listed under `receivers` for that label. Drivers that need them, such as email, declare that themselves | `MAIN-EXFIL-0002` |
| Response | More labelled records than `responses.bulk` allows (or than the action's own `bulk`), or than one actor may read over `actor-window` | `MAIN-EXFIL-0003` |
| Response | A resolved secret, or a card number that passes the Luhn check and has a known network prefix: replaced with a 500 | `MAIN-EXFIL-0004` |
| Any body over `scan.max` | The rest is not scanned; logged once a minute per route | `MAIN-EXFIL-0005` |

`mode` is `observe` (log a would-block), `warn` (log and write a security event) or `enforce` (block). A push of `main.exfiltration.mode` can only move toward `enforce`; anything looser is refused with `MAIN-CONFIG-0021`.

## Canaries

Canary records belong to a canary owner in each region (`canary-us`, `canary-eu`, `canary-in`), so an owner-scoped query never returns one and raises nothing. A cross-owner read, an unscoped support query or a dump that returns one raises a single critical `canary.touched` event with `MAIN-CANARY-0001`; the response is unchanged unless `on-touch: block`. The registry of ids is kept as HMAC-SHA256 values under `MAINSDK_CANARY_KEY` from `secrets`, so a reader of the database cannot tell which rows are canaries. `canary.status` and the CLI show counts only.

With `keys: true`, plant also stores a decoy API key as `MAINSDK_CANARY_TOKEN`, referenced by no code. Presenting it to `apikeys.verify` is refused like an unknown key (`MAIN-APIKEY-0002`) and raises `canary.key_used` with `MAIN-CANARY-0002`; so does sending it out or returning it.

```text
mainsdk canary plant --env staging
mainsdk canary status --env staging --json
```

`mainsdk check` reports `MAIN-EXFIL-0006` for an invalid `exfiltration` value and `MAIN-CANARY-0003` for a model that does not exist or a canary set with nothing planted in the env.

## Errors and checks

`mainsdk check` raises `MAIN-EXFIL-0006` and `MAIN-CANARY-0003`; the rest are logged or returned at run time.

- `MAIN-CANARY-0001`
- `MAIN-CANARY-0002`
- `MAIN-CANARY-0003`
- `MAIN-EXFIL-0001`
- `MAIN-EXFIL-0002`
- `MAIN-EXFIL-0003`
- `MAIN-EXFIL-0004`
- `MAIN-EXFIL-0005`
- `MAIN-EXFIL-0006`

Each code's cause and fix is in [check codes](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

There is one driver, so there is nothing to swap today. The calls and `main.yaml` sections do not depend on it.

| Driver | Provider | |
| --- | --- | --- |
| `builtin` | Built in (an Aho-Corasick matcher on the standard library) | default |

## Testing

`@mainsdk/testing` turns the guard on with an empty `canaries` section, and its attack simulator can drive a canary touch, all offline with the built-in matcher. See [Test your app](https://mainsdk.dev/docs/guides/testing).

## Next

- [Network permissions](https://mainsdk.dev/docs/interfaces/network)
- [Secrets](https://mainsdk.dev/docs/interfaces/secrets)
- [API keys](https://mainsdk.dev/docs/interfaces/apikeys)
