Canary secrets
A canary is a fake credential you place next to the real ones. Nobody legitimate ever uses it, so a single use is a high-signal alert: an intruder dumping a container's environment, a .env leaked to a paste site or a public repository, a compromised CI runner replaying secrets, or someone with read access poking around.
What canaries are, and why they work
Most alerting has to guess what normal looks like. A canary does not: its only normal state is never used. One trip is enough to act on, with no agent, scanner or SIEM. You can place one, leak it on purpose and watch the alert arrive in about thirty seconds.
A canary has two halves. The trigger is what Dynv's server can recognise: an opaque id, with no name attached. The label holds the variable name, its value and where it was placed, sealed so that only the account's owners can open it. Keeping the halves apart is what lets a zero-knowledge server alert without learning your variable names.
Kinds
| Kind | The variable holds | What trips it |
|---|---|---|
| Tripwire token | A real-format dynv_pt_… or dynv_mt_… token. | Any authenticated request that uses it: dynv run, an SDK, curl. It always fails with the same 401 a revoked token gets. |
| URL / webhook | A URL on a host that routes to a canary listener: a public wildcard-IP hostname, a host on a domain you own, or a self-hosted listener. | Any HTTP request to it. |
| DNS | A hostname such as cache-replica-<trigger>.<zone>. | Resolving it, from an app, nslookup or a scanner. |
| Database URL | A connection string such as postgres://app_ro:<password>@pg-<trigger>.<zone>:5432/app, also mysql://, redis:// and mongodb://. | Resolving the host, which any attempt to connect does. |
The API kind is token, url or dns. Database URLs are DNS canaries with a postgres, mysql, redis or mongodb shape.
The tripwire token is the flagship. It is fully in Dynv's control and indistinguishable from a real token: an intruder who finds CI_DYNV_TOKEN_OLD beside the live DYNV_TOKEN and tries it gets exactly the 401 a stale token gets, and you get the alert. Answering a canary with fake success would be more convincing for a moment, but it is a second code path an attacker could probe, and comparing it with what the process actually sees would reveal the fake. "Revoked" is the most believable answer anyway.
Where a URL canary points
A URL canary needs a host that reaches a listener. You don't need a Dynv-owned domain: pick a base per canary with --base, or use the server's default, in this order of convenience.
- Public wildcard-IP hostnames. The default base is a wildcard-IP hostname such as
http://*.203.0.113.7.sslip.io:*becomes the trigger, and the hostname resolves to the listener's IP through a public service (sslip.io,nip.ioortraefik.me). The generated URL looks likehttp://<trigger>.203.0.113.7.sslip.io/services/T…/B…/<random>. - One IPv6 address per canary. With
http://{ip6}.sslip.ioand aDYNV_CANARY_IP6_PREFIX/64, each canary gets its own address in that prefix; the trigger is the 16-hex interface id and the host is the dashed IPv6 address. - Your own domain.
--base https://*.hooks.acme.computs the trigger in the first host label; CNAME the wildcard to the canary listener. The base is stored in the canary's sealed label, so a rotation keeps using it. The listener must accept the host: setDYNV_CANARY_ANY_HOST=1or list it inDYNV_CANARY_HOSTS. - Your own listener.
dynv canary listen --addr :8080runs a listener that reports requests to Dynv with a process token (DYNV_URLandDYNV_TOKEN). Point a canary at it with--base http://127.0.0.1:8080, or at any host that reaches it. The requesting side talks only to your listener; nothing in the URL, DNS, certificate or response points at Dynv.
Hosted modes are plain http:// in phase 1, like Coolify's default; your-domain and listener modes use your own TLS. The public wildcard-IP services are free and have no SLA: if one stops resolving, its canaries stop tripping silently, and some corporate DNS filters block them. In those modes a DNS lookup alone does not trip; only a connection does.
Our-zone DNS canaries remain available (DYNV_CANARY_DNS_ZONE), and their hosts also answer HTTP on the canary listener. A host is recognised as a canary only on the listener, never on the Dynv API host.
What we don't offer, and why
- AWS access keys are not built yet. A convincing decoy needs a Dynv-owned AWS account and CloudTrail, and AWS key ids encode the account id;
sts:GetAccessKeyInforeveals it without signing in. That is ongoing work, not a flag we can switch on honestly. - GitHub tokens can't alert. GitHub does not report token use to third parties, so there is nothing for us to observe. A format-only decoy that can't alert is noise, so we don't offer one.
- Stripe, Twilio, OpenAI and similar keys can't alert either. Those services tell nobody about a failed authentication but themselves. There is no honest signal to attach to, and we won't sell a decoy that only looks the part.
The rule is simple: a canary is offered only when its use produces a signal Dynv can actually observe.
Where a canary goes
- In a service (
--in <service>): the variable is written into the service's env document and delivered to the process like any other, so dumping/proc/<pid>/environ, runningenvor reading a crash report shows it next to the real values. - In a shared group (
--group <group>): the same, in every service attached to the group. - Decoy only (
--decoy): never injected anywhere. The value exists only in its sealed label. Get it withdynv canary export --decoy-envfor a realistic.envto leave on a laptop, a bastion, a backup bucket or a wiki page, withdynv canary envfor a CI secret store, or from the dashboard.
A decoy is never in an env document with a "don't inject" flag, because process tokens read env documents: the flag itself would tell an intruder which variable is fake.
Clients suggest realistic names, generated locally and avoiding names that already exist in the target. Examples: CI_DYNV_TOKEN_OLD, SLACK_WEBHOOK_URL_ALERTS, REDIS_HOST_REPLICA, DATABASE_URL_REPLICA. A delivered variable can't use the DYNV_ prefix, which is reserved in env documents; a decoy .env may use any name. The name is a hint, not a requirement: --name accepts any valid variable name.
What Dynv's server can and can't see
The canary key is owner-only. It is derived from the account's signing seed, which only owner keyrings hold, so labels cannot be opened by viewers, recovery masters or process tokens. That is deliberate: a canary has to stay secret from everyone who might snoop, including people with read access to the account.
| Party | Learns | Doesn't learn |
|---|---|---|
| Dynv's server or a database dump | That an account has canaries, their kinds and triggers, when they were created or paused, and each trip's time, source, IP, country, user agent, method or query type. | Variable names, values, whether a canary is delivered or decoy-only, or which service or group it is in. |
| A process-token holder | The variable and its value, like every other variable. | That it is a canary: a delivered canary is an ordinary variable in the signed env document, with no flag. |
| A viewer or recovery master | Nothing: listing canaries and trips answers them exactly as for an account with none. | That canaries exist, their names, placement, kinds or trips. They can't tell which variable in an env document is the canary. |
| An owner | Everything, decrypted in the browser, CLI or MCP. | — |
Residual risks, stated plainly:
- Creation timing. Creating a delivered canary is one canary write plus one ordinary env write a moment later, and the server or a viewer reading the audit log could correlate them. Create canaries in the same change as real edits, or use decoys, which never touch an env document.
- Public wildcard-IP DNS services see the hostname (the listener IP and the trigger), and a shared DNS zone sees every name in it. Your own domain or a per-canary IPv6 address avoids a shared public name; tokens do not have this problem.
- The server knows which token ids are canaries, because it has to. It can't learn names, and it can't fabricate a token trip without the canary's auth key. A compromised server could still refuse to alert.
- Owners can always see canaries. They catch outsiders and readers, not the account's owners.
Alerts
Canary trips reuse the account's existing Security alert channels from vulnerability monitoring; there is no second settings page. Email goes to the verified alert address only. Webhooks use the same signing, SSRF rules, retries and sealed URLs as vulnerability alerts, and a canary trip counts as critical for thresholds.
A webhook limited to a list of services does not receive canary trips. The server doesn't know which service a canary is in, so it can't honour a service scope.
Every alert contains the canary id and kind, the source (api, http, dns, listener or test; overflow, at most once an hour per canary, when it was used so heavily that some uses were only counted, with how many), time, IP, country when a trusted proxy supplies it, the sanitised user agent, the method or DNS query type, and a link to the canary in the dashboard. It never contains the variable's name: the server can't read it. The dashboard and CLI show the name because they decrypt it locally.
- Pause is the per-canary kill switch. Trips are still recorded and shown, but never delivered.
- Dedupe: one delivery per
(canary, IP, source)per 10 minutes. Later trips are kept in the feed as suppressed. - Budgets: 6 deliveries per canary per hour, then 30 per account per hour across all canaries, with separate counters from vulnerability alerts. Over budget stays in the feed, marked suppressed. The next delivered alert says how many trips were held back.
- The first use is always delivered, unless the whole account budget is already spent within that hour.
- Bounded storage: at most 100 trips per canary per hour and 2,000 per account per hour, then a counter is incremented instead. At most 200 canaries per account.
DNS trips have known noise. The lookup comes from the victim's resolver, not the attacker, so the recorded IP is often a public resolver or a corporate one. Resolvers cache, so a second lookup within the TTL may not trip again. Security products that pre-resolve hostnames found in files can cause false positives. These trips are labelled source: dns.
A user agent that looks like a link preview or chat unfurler (Slackbot, Twitterbot, Googlebot and similar) still alerts, with a note that someone probably pasted the canary somewhere. Operators can list their own scanners in DYNV_CANARY_IGNORE_CIDRS: those trips are recorded but never delivered.
Nothing in Dynv dereferences a variable's value, so the canaries do not trip on Dynv's own systems. The one deliberate path is dynv canary test, which records a source: test trip and delivers it as [test].
Not fingerprintable
A canary is only useful if an attacker can't tell it apart from a real secret that no longer works.
- Tokens are ordinary tokens. Same format, same entropy, no marker bits and no special id range. Failed authentication returns the unchanged
401that unknown and revoked tokens get, through the same layers. The request path doesn't change: the attempt is queued and matched by a background task, so the answer and its timing never depend on whether the id is a canary. A canary id behaves like an unknown id: its failures count against the IP's budget, and after 20 failures per (IP, id) in 5 minutes it gets the same429.auth.failis written as for an unknown id; the trip has its owncanary.tripaudit event. - The URL host answers the same way for every path.
POST,PUTandPATCHget200 ok;HEAD,GETand the rest get404 not found. That holds whether or not a trigger matched, whether it is paused or deleted, and whether the request was rate-limited. There are no Dynv headers, JSON errors or CORS: it looks like a webhook receiver, not the Dynv API. The API is never served on a canary host. - DNS answers are uniform. Every name under the zone gets the same
Arecord and TTL of 60, matched or not.
What links canaries together depends on the mode:
- Tokens and your own listener: nothing.
- Your domain: your domain, which only you know.
- Wildcard-IP host: the server's IPv4 address, shared by every canary on it. It is not the API address, and its reverse DNS is the provider's.
- Per-address IPv6: the /64 prefix.
- Our DNS zone: the zone.
A signed-up user can read the server's bases and zone from GET /v1/canaries; they are never in the public /v1/meta. For hosted modes, the canary IP should not serve the API: put the API behind a CDN or on another address. The public DNS service that answers a wildcard-IP name sees the hostname (the listener IP and the trigger), never the variable or its value.
CLI
Creating, changing and listing canaries and trips need an owner (writer) master token. Any other master token gets an empty list, the same as an account with no canaries. canary listen uses a process token instead.
dynv canary create --kind token|url|dns|db [--in <service> | --group <g> | --decoy]
[--name NAME] [--token-kind pt|mt] [--shape webhook|discord|api]
[--base <url base>] [--db postgres|mysql|redis|mongodb] [--json]
dynv canary ls [--json]
dynv canary trips [--canary ID|NAME] [--json] [--watch] [--limit N]
dynv canary rotate <ID|NAME> # new trigger and value; rewrites the variable in place
dynv canary pause|resume <ID|NAME>
dynv canary rm <ID|NAME> [--keep-var] # also removes the variable from its service or group
dynv canary test <ID|NAME>
dynv canary export --decoy-env # a realistic .env of every decoy-only canary, to stdout
dynv canary env [ID|NAME…] # KEY=VALUE lines for CI secret stores
dynv canary listen --addr <addr> # self-hosted listener; reports trips with DYNV_TOKEN| Command | Meaning |
|---|---|
dynv canary create --kind … | Create a canary and generate its value locally. --token-kind picks pt or mt, --shape picks the URL shape, --base overrides the URL host, and --db picks the scheme for --kind db. The output carries the canary id, name, kind, shape and placement, never the value; use export --decoy-env or env to read it. Without --in, --group or --decoy, the CLI asks where it should go. |
dynv canary ls [--json] | List canaries with kind, shape, placement, trip count, last trip and paused state. Names are shown only to an owner. |
dynv canary trips [--canary …] [--limit N] | Recent trips, newest first, with the decrypted name. --watch --json prints one JSON object per line as trips arrive, polling every 5 seconds; pipe it into jq or an agent. |
dynv canary rotate <ID|NAME> | Generate a new trigger and value and rewrite the variable in place. The old trigger stops matching at once. |
dynv canary pause|resume <ID|NAME> | Stop or resume alert delivery. Trips keep being recorded while paused. |
dynv canary rm <ID|NAME> [--keep-var] | Delete the canary and its trips, and remove the variable from its service or group unless --keep-var is given. |
dynv canary test <ID|NAME> | Record a test trip and deliver it through the alert channels, marked [test]. Limited to 5 per account per hour. |
dynv canary export --decoy-env | Print a realistic .env containing every decoy-only canary, to stdout. |
dynv canary env [ID|NAME…] | Print KEY=VALUE lines for the named canaries, for pasting into a CI secret store. Decoy-only canaries by default. |
dynv canary listen --addr <addr> | Run a listener that accepts URL canaries and reports each request to Dynv with DYNV_TOKEN. Trips show source: listener. Nothing in the canary URL points at Dynv. |
MCP
| Tool | Available | Returns |
|---|---|---|
list_canaries | always, read-only | Canary ids, kinds, shapes, placement and trip counts. Names appear only when the MCP's master token is an owner; otherwise name: null. |
list_canary_trips | always, read-only | Trips with {canary?, limit?, since?}: time, source, IP, country, method or query type and delivery status. The user agent, method and query type are returned under untrusted: they were sent by whoever used the canary. |
create_canary | with --allow-canary-create | The canary id, name, shape and placement. Never the value. A placed canary's name must match --allow-keys; a decoy needs no key scope. |
An agent doesn't need the fake secret, and an agent transcript is exactly where a value should not leak, so create_canary never returns one. Decoy .env files are a CLI and dashboard feature. MCP writes carry origin: "mcp" and a change id like every other MCP write.
HTTP API
Reads accept any master token; writes need an owner (writer) master token. The HTTP API reference has the full shapes.
| Method | Path | Auth | Body → Response |
|---|---|---|---|
GET | /v1/canaries | master | → CanariesResp {canaries, config} |
POST | /v1/canaries | writer | CreateCanaryReq {canary_id, kind, trigger, verifier?, label_ct} → 201 CanaryInfo |
PUT | /v1/canaries/{id} | writer | PutCanaryReq {trigger?, verifier?, label_ct?, paused?} → CanaryInfo. A new trigger is a rotation and the old one stops matching at once. |
DELETE | /v1/canaries/{id} | writer | → 204. Deletes its trips. |
GET | /v1/canaries/trips?limit=N&canary=ID&since=MS | master | → CanaryTrip[], newest first; limit 1–500, default 50. |
POST | /v1/canaries/{id}/test | writer | → CanaryTrip with delivered. At most 5 per account per hour. |
POST | /v1/self/canary-trips | process token | SelfCanaryTripReq {host, path, method, ip, user_agent?} → 204. A self-hosted listener's report; matches only this account's URL canaries. |
CanaryInfo {canary_id, kind, trigger, label_ct, paused, created_at, updated_at, trips, last_trip_at?, dropped}.CanaryConfig {url_bases[], ip6_prefix?, dns_zone?, max_canaries}.url_basesis the server's URL bases, default first (empty if it has none; a client then needs--base).dns_zoneis absent when kinddnsis unavailable. Creating adnscanary without a zone, or an IPv6-triggered URL canary without a prefix, fails with400 canary_kind_unavailable.CanaryTrip {id, canary_id, kind, source, at, ip, country?, user_agent?, method?, qtype?, bot, ignored, suppressed, held_back?, delivered}. Trips are kept for 90 days.
Self-hosting
Kind token works on any server. URL canaries need a host that reaches a listener (the server's first base, or your own --base); DNS and database-URL canaries need a delegated zone.
| Variable | Meaning |
|---|---|
DYNV_CANARY_URL_BASES | Comma-separated URL bases, the default first, for example http://*.203.0.113.7.sslip.io,http://{ip6}.sslip.io. * is replaced by the trigger; {ip6} by a fresh address in DYNV_CANARY_IP6_PREFIX; otherwise the trigger goes in the path. |
DYNV_CANARY_IP6_PREFIX | The canary IPv6 /64, for example 2a03:4000:1:2::/64. Required for a {ip6} base. |
DYNV_CANARY_HOSTS | Host patterns served by the canary handler: an exact host or *.suffix. Use it for a domain you own or for the wildcard-IP hosts, or rely on DYNV_CANARY_ANY_HOST. |
DYNV_CANARY_ANY_HOST | Set to 1 to send every host except DYNV_API_HOSTS to the canary handler, for customer domains CNAMEd to this server. Careful: a host missing from DYNV_API_HOSTS, including a new alias or a request by IP, then gets the canary's 404 instead of the API. The server refuses to start without DYNV_API_HOSTS. Prefer DYNV_CANARY_HOSTS. |
DYNV_API_HOSTS | The hosts that serve the Dynv API. Used by DYNV_CANARY_ANY_HOST to keep API traffic out of the canary handler. |
DYNV_CANARY_LISTEN | An extra listener that serves only the canary handler, for raw IP traffic, for example [::]:8081 for the /64. Off when unset. |
DYNV_CANARY_DNS_ZONE | The delegated zone, for example int-cache.net. Required for kinds dns and db. |
DYNV_CANARY_DNS_ADDR | UDP listen address of the responder, for example 0.0.0.0:53, or :5353 behind a port mapping. Off when unset. |
DYNV_CANARY_DNS_A | The IPv4 address every name answers with. Point it at the canary HTTP host, so a DNS lookup followed by an HTTP request trips twice. |
DYNV_CANARY_IGNORE_CIDRS | Comma-separated CIDRs whose trips are recorded but never delivered. Use it for your own scanners and monitoring. |
DYNV_TRUSTED_PROXIES | CIDRs of the proxies directly in front of the server. Forwarded client-IP and country headers are believed only from these peers. Required whenever DYNV_CLIENT_IP_HEADER or DYNV_CANARY_CLIENT_IP_HEADER is set. |
DYNV_CANARY_CLIENT_IP_HEADER | The client-IP header on canary hosts, for example x-real-ip set by Traefik. It must be one your proxy overwrites. Unset: the connection's address. The API's DYNV_CLIENT_IP_HEADER is never used for canary hosts, and DYNV_CANARY_LISTEN always uses the connection's address. For listener trips, the address is what your own listener reported. |
The default wildcard-IP mode needs no domain of your own: point the base at the listener's IP and let sslip.io, nip.io or traefik.me resolve it. If you use your own domain, CNAME it to the listener. A delegated DNS zone still needs an NS delegation and UDP 53 published on the container host; per-address IPv6 needs the /64 accepted on the host. Without any of this, token canaries work alone, and URL canaries still work in your-domain and listener modes. The old single DYNV_CANARY_URL_BASE is gone; the first entry in DYNV_CANARY_URL_BASES is the default.
Thirty-second demo
Create a decoy token, use it by mistake, and watch the alert arrive.
# 1. Create a decoy token. It is never injected into any service.
dynv canary create --kind token --decoy --name CI_DYNV_TOKEN_OLD
# 2. Read its value from the decoy .env.
dynv canary export --decoy-env
# 3. Use it as a service token. It fails exactly like a revoked token, and trips.
DYNV_TOKEN=<the decoy token> dynv run -- true
# 4. Watch trips as they arrive.
dynv canary trips --watch --jsonThe trip appears in dynv canary trips and in the dashboard, and is delivered to your verified alert email and to any webhook that isn't limited to services. The email shows the canary id, kind, source, IP, country, user agent, time and a link; the variable's name is shown only in the dashboard, because Dynv's server can't read it.
Last updated