Use a secret without knowing it.
BetaUsing a secret with an AI agent usually means putting the plaintext where the agent can read it — a chat message, a file it opens — so the value enters the model's context, the transcript, and every log. The broker inverts that: the agent works with a stable, non-sensitive reference; TillSecrets materialises the real value only at the outbound egress boundary, with your consent, against an allow-listed destination, and audits every use. The value never touches the model, the chat, the agent's memory, or a log.
References
A reference is a URI-shaped handle that is safe to appear anywhere — chat, prompt, file, PR, log — because it is not the value and reveals nothing beyond the name (and the name is already governed by the Secrets-layout egress rules). It resolves only inside the broker, and only for an entitled actor with consent; it is inert everywhere else.
secretref://<project>/<env>/<KEY> # canonical
{{ts:<project>/<env>/<KEY>}} # terse, for env / template files
# e.g.
secretref://infra/prod/NEON_DATABASE_URLproject and env are your TillSecrets slugs; KEY is the secret's env-var name. Only static secrets are brokerable today (dynamic leases resolve through their own leasing flow).
The fail-closed chain
Every spend — a generic HTTP request or a catalog action — passes the same checks, in order, and stops closed at the first failure:
- SSRF — the destination must be a concrete, public HTTPS host (never a private / metadata address).
- Resolve — the reference must name a real secret in your org.
- Entitlement — the acting human must be able to read the secret at its sensitivity tier (a restricted, prod-grade secret needs owner; others need admin).
- Destination allowlist — the host must be on the secret's own allowlist. Enforced above consent — see below.
- Consent — a broker grant must cover
(secret, agent, host)and still be usable. - Claim — one use of the grant is claimed atomically; a revoked/exhausted grant fails closed here.
- Inject — the plaintext is decrypted in the broker and substituted into only the bytes that leave.
- Response-scrub — any echo of the value in the reply is re-tokenised back to its reference before the caller sees it.
- Audit — the spend is recorded (reference, destination, agent, approving human) — never the value.
Consent & the destination allowlist
Two independent controls gate every spend. The destination allowlist is a per-secret list of the only hosts the value may ever reach (an exact host like api.stripe.com, or a wildcard like *.stripe.com). It is enforced above consent: even a consenting — or socially-engineered — user cannot send the key to an off-list host. A consent grant then authorises one agent to spend one secret against one allowed host, at a scope you choose.
# 1. Allow-list the ONLY hosts this secret may ever be spent against.
# Enforced ABOVE consent: a tricked/consenting user still can't redirect it.
tilldev secrets broker allow <secret-id> api.stripe.com
# 2. Consent: agent X may use the secret against that host — once / session / ttl.
tilldev secrets broker grant create secretref://billing/prod/STRIPE_SECRET_KEY \
--agent ask-x --host api.stripe.com --scope ttl --ttl-minutes 60
# See consent + every spend (never a value):
tilldev secrets broker grants
tilldev secrets broker spends| Scope | Meaning |
|---|---|
once | A single spend, then spent. |
session | Any number of spends within one bound session id. |
ttl | Any number of spends until the expiry you set (--ttl-minutes). |
The first spend of a new (reference → host) pair is where a human grants consent — approve once, for the session, or TTL-boxed — like granting an OAuth scope. After that the agent spends the reference repeatedly with no re-paste and no per-call re-approval. Revocation (broker grant revoke) takes effect immediately: an in-flight spend against a just-revoked grant fails closed.
A destination can also be limited to certain methods and path prefixes. A prefix matches whole path segments, so /v2/droplets covers /v2/droplets/123 but not /v2/droplets-old. Paths with encoded slashes or dots never match. The broker checks the rules again after it substitutes the value, so a reference hidden in the path cannot move the request elsewhere.
# Narrow a destination to the calls the secret is actually for. Re-run to change
# the rules; --any-method / --any-path widen them again.
tilldev secrets broker allow secretref://infra/prod/DIGITALOCEAN_TOKEN api.digitalocean.com \
--methods GET,DELETE --path /v2/droplets
tilldev secrets broker allow ls secretref://infra/prod/DIGITALOCEAN_TOKEN
# HOST METHODS PATHS NOTE ID
# api.digitalocean.com GET,DELETE /v2/droplets 3f0c…secrets exec
On your own machine, secrets exec materialises references into a child process's real env at launch — never printed, never in your shell's env. Keep a .env.refs of references (safe to commit) instead of a .env of secrets:
# .env.refs — values are references, not secrets. Safe to commit.
DATABASE_URL=secretref://infra/prod/NEON_DATABASE_URL
STRIPE_SECRET_KEY=secretref://payments/prod/STRIPE_SECRET_KEY# Run a command with the references materialised into ITS process env only —
# never printed, never in your shell's env. Fail-closed: if a reference can't
# resolve (or you're not entitled), the command does not run.
tilldev secrets exec --env-file .env.refs -- npm run migrate
# Layer several files; for a key in more than one, the later file wins and
# the override is named on stderr (key and file names only):
tilldev secrets exec --env-file .env.refs --env-file .env.pod.refs -- node server.js
# Inspect what a file references (metadata only — no values ever):
tilldev secrets refs .env.refsEach materialisation is entitlement-checked and audited to a local destination. If any reference can't resolve, the command does not run (pass --allow-partial to override).
On a headless host — a systemd unit, a CI runner — you don't even keep a file of references. The --env <slug> form authenticates with a ts_ service token and pulls the whole environment from the vault into the child process env: the environment is the manifest, so nothing — not the values, not even the list of references — lives in a file on the box.
# Headless host (a systemd unit, a CI runner): there is no local file of
# references to keep — the ENVIRONMENT itself is the manifest. A ts_ service
# token names what may be read; the whole environment's config is pulled into
# the child process env only (never printed, never in your shell).
TILLSECRETS_TOKEN=ts_… TILLSECRETS_URL=https://tilldev.dev \
tilldev secrets exec --env prod -- node server.jsexec --env-file (references, never values) and exec --env <slug> (nothing on disk at all), a service can boot with its full configuration without a single plaintext secret — or even a secretref:// — ever being written to the machine.The local bridge
The broker's safest home is the local-agent bridge. When the bridge runs your agent, it stands up a loopback detokenising endpoint on 127.0.0.1 and injects two variables into the agent's environment: TILLDEV_BROKER_URL and a per-run TILLDEV_BROKER_TOKEN. The agent posts an outbound request that carries a reference to $TILLDEV_BROKER_URL/spend — the same shape as the cloud endpoint — and the real value is injected into the outbound bytes on your machine.
# On the local-agent bridge, the shim runs on 127.0.0.1 and the agent's env
# carries TILLDEV_BROKER_URL + TILLDEV_BROKER_TOKEN. The agent posts an outbound
# request that carries a reference to the LOCAL endpoint — same shape as the cloud
# /broker/spend — and the value is injected + spent on THIS machine.
curl -X POST "$TILLDEV_BROKER_URL/spend" \
-H "authorization: Bearer $TILLDEV_BROKER_TOKEN" \
-H "content-type: application/json" \
-d '{
"method": "POST",
"url": "https://api.stripe.com/v1/charges",
"headers": { "Authorization": "Bearer secretref://payments/prod/STRIPE_SECRET_KEY" },
"body": "amount=2000¤cy=usd"
}'
# The value never enters the agent's context, and the request body never leaves
# your host — only the destination host + the reference reach TillDev to authorise.Only the destination host and the non-sensitive references reach TillDev, which runs the full chain and authorises the spend as bridge:<device> (audited as a bridge.spend). The plaintext and your actual API traffic never transit the cloud — the leakage surface for that class of task drops to near-zero. The shim is on by default; disable it per device with TILLFORGE_BRIDGE_BROKER=off.
Catalog actions
Some privileged actions don't map to a single HTTP request — a database migration, for instance, speaks the Postgres wire protocol. A catalog action is a curated executor that performs the action itself and returns a result (rows applied, a deployment id) — never the materialised secret. It runs the same authorize → consent → allowlist → audit chain.
# Catalog action: apply a migration to a database named ONLY by reference.
# The connection string never reaches your terminal or the agent.
tilldev secrets migrate \
--ref secretref://infra/prod/NEON_DATABASE_URL \
--file migrations/0007_add_orders.sqlSo the first time you spend a given connection secret, expect two fail-closed refusals in a row — each names the gate to open next. Walk this once per secret and every later migrate just runs:
# The FIRST time you spend a CONNECTION secret (a DATABASE_URL, whose host lives
# INSIDE the value) two one-time gates must be set. Each refusal is fail-closed
# and names the next step, so you can walk it top to bottom:
# 1. Allow-list the DB host on the secret (owner-only for a restricted secret).
# Skipped → refusal: no_destination_allowlist
tilldev secrets broker allow <secret-id> db.prod.example.com
# 2. Grant consent for the agent to spend the secret against that host.
# Skipped → refusal: needs_consent
tilldev secrets broker grant create secretref://infra/prod/DATABASE_URL \
--agent cli --host db.prod.example.com --scope ttl --ttl-minutes 15
# 3. Apply. The connection string is spent INSIDE the broker — it never reaches
# your terminal — and the broker re-checks the revealed host matches step 1.
tilldev secrets migrate --agent cli \
--ref secretref://infra/prod/DATABASE_URL --file migrations/0007.sql| Refusal code | What it means | Fix |
|---|---|---|
no_destination_allowlist | The secret has no single concrete allow-listed host to spend against. | broker allow <secret-id> <host> |
needs_consent | No usable grant covers (this secret, this agent, this host). | broker grant create secretref://… --agent … --host … |
destination_blocked | The host is not allowed for this secret, or the revealed connection string points somewhere else. | Allow the host, or re-point the secret so they agree. The value was not used. |
method_blocked | The host is allowed, but not with this HTTP method. | broker allow <secret> <host> --methods … |
path_blocked | The host is allowed, but not this path, or substituting the value changed the path. | broker allow <secret> <host> --path … |
not_entitled | Your role can’t read the secret at its tier (7+ needs owner). | Have an owner run it, or lower the secret’s or environment’s tier (an owner must do that). |
out_of_scope | An agent token spent a reference outside its ref patterns. Nothing was decrypted. | Mint a token with the right --refs. See Agent tokens. |
budget_exhausted | An agent token used its daily budget. It resets at 00:00 UTC. | Wait, or mint a token with a higher --budget. |
migrate splits your file and runs statements one at a time with no per-file transaction, stopping at the first failure. Write them to be safely re-runnable — CREATE … IF NOT EXISTS, ADD COLUMN IF NOT EXISTS — so a partial apply can simply be run again.The second flagship action deploys a Cloudflare Worker: the agent supplies the script and names the API token only by reference. Because the destination is a real, fixed HTTPS host, it runs the standard chain against api.cloudflare.com (which must be on the token secret's allowlist), uploads the Worker, and returns the deployment result — the token is spent, never seen.
# Catalog action: deploy a Cloudflare Worker with the API token named ONLY by
# reference. The token never reaches your terminal or the agent — the broker
# reveals it against api.cloudflare.com (which must be on the token's allowlist),
# uploads the Worker, and returns the deployment result.
tilldev secrets deploy-worker \
--ref secretref://infra/prod/CLOUDFLARE_API_TOKEN \
--account 0123456789abcdef0123456789abcdef \
--name my-worker --file dist/worker.js --compat-date 2026-07-01Anything outside the catalog falls back to the generic broker: post the outbound request to /api/secrets/broker/spend with the reference in a header or the body, and the broker injects the value toward the one allowed host.
# The server-side detokenising proxy. Post an outbound request that carries a
# reference; the broker injects the real value into ONLY the bytes that leave and
# returns a scrubbed response. The value never comes back to the caller.
curl -X POST https://tilldev.dev/api/secrets/broker/spend \
--cookie "$SESSION" -H "content-type: application/json" \
-d '{
"method": "POST",
"url": "https://api.stripe.com/v1/charges",
"headers": { "Authorization": "Bearer secretref://payments/prod/STRIPE_SECRET_KEY" },
"body": "amount=2000¤cy=usd",
"agent_key": "ask-x"
}'Presigned calls
Some machines need to make one privileged call and should hold nothing else. A droplet that deletes itself when a job ends is the usual case: giving it the DigitalOcean token would let it delete every droplet you own. A presigned call approves one exact request now. The request can carry secretref:// handles in its URL, headers or body. The machine gets a tsc_ token that can only run that request.
# On your machine, while you provision: approve ONE call for later.
# The tag is unique to this job, so the call can only delete this job's droplets.
tilldev secrets presign DELETE "https://api.digitalocean.com/v2/droplets?tag_name=job-42" \
--bearer secretref://infra/prod/DIGITALOCEAN_TOKEN \
--max-uses 1 --expires 6 --quiet > call.token # tsc_…, shown once
# Pass call.token to the droplet (cloud-init user data, a file you copy in).
# On the droplet, when the job is done:
TILLSECRETS_CALL_TOKEN="$(cat /etc/job/call.token)" tilldev secrets call
# Or with curl, no CLI needed:
curl -sf -X POST https://tilldev.dev/api/secrets/broker/call \
-H "Authorization: Bearer $(cat /etc/job/call.token)"- Checked twice. At mint time the request must pass the destination rules and your access. When it runs, the broker checks the rules again, and you must still be an admin in the org.
- Bounded.
--max-uses(1–100, default 1),--expiresin hours (default 24, up to 90 days) and an optional--not-before. A use is claimed before the secret is substituted, so two racing calls cannot both spend the last use. - Nothing to leak. The token cannot read a secret or change the request. The template is stored encrypted and the token only as a hash. The response comes back with any secret value scrubbed.
- Revocable.
tilldev secrets presigned revoke <id>, or from the broker console. Every run is in the spend ledger under the agentpresigned:<first 8 of id>.
Bound to where it runs
A token can leak: into a log, a support ticket, or the instance metadata any process on the machine can read. Two optional bindings make a leaked token useless anywhere else.
- Runs from (
--from, up to 16): addresses or CIDR ranges, IPv4 or IPv6. A bare address means just that address. The address checked is the one TillDev sees, so a machine behind NAT is seen as the NAT's public address. - Machine key (
--bind-key): an Ed25519 public key, as anssh-ed25519line,ed25519:<base64>or PEM. Each redemption must then carry a proof signed by the private half, so the token is worthless without it. A server's SSH host key works, and nothing new has to be put on the machine.
# 1. On the machine: its SSH host key already exists and only root can read it.
cat /etc/ssh/ssh_host_ed25519_key.pub
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI… root@job-42
# 2. On your machine: presign, bound to that key and the machine's address.
tilldev secrets presign DELETE "https://api.digitalocean.com/v2/droplets?tag_name=job-42" \
--bearer secretref://infra/prod/DIGITALOCEAN_TOKEN \
--from 203.0.113.7 --bind-key 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI…' \
--quiet > call.token
# 3. On the machine, as root. The token alone is refused; --key signs each run.
TILLSECRETS_CALL_TOKEN="$(cat /etc/job/call.token)" \
tilldev secrets call --key /etc/ssh/ssh_host_ed25519_key
# No host key to use? Make a machine key there instead:
tilldev secrets call keygen # private key to ~/.tilldev/call-key.pem, public line on stdoutThe proof, sent as x-tilldev-proof: v1.<unix ms>.<signature>, signs the SHA-256 of the token and the time. It is accepted within two minutes of TillDev's clock, and each one must be newer than the last one accepted, so a copied proof cannot be used again.
# Without the CLI (GNU coreutils + OpenSSL 3, key in PEM form, e.g. from keygen):
TOKEN=$(cat call.token)
AT=$(date +%s%3N)
HASH=$(printf %s "$TOKEN" | sha256sum | cut -d' ' -f1)
SIG=$(printf 'tilldev-presign:v1\n%s\n%s' "$HASH" "$AT" |
openssl pkeyutl -sign -rawin -inkey call-key.pem -in /dev/stdin |
base64 -w0 | tr '+/' '-_' | tr -d '=')
curl -sf -X POST https://tilldev.dev/api/secrets/broker/call \
-H "Authorization: Bearer $TOKEN" -H "x-tilldev-proof: v1.$AT.$SIG"A refused attempt uses nothing. It is counted on the call in the broker console and in tilldev secrets presigned ls, recorded in the audit log as presign.refused with the address it came from, and emailed to whoever presigned the call, at most once an hour per call. If the attempt wasn't yours, revoke the call.
--max-uses and --expires tight.Runs by itself
Sometimes the call should happen even if the machine never makes it: the job hung, crashed, or lost its network. A presigned call can carry a timer, and then TillDev runs it itself.
- At a time (
--at): a date such as2026-10-01T18:00:00Z, or+30m,+2h,+1dfrom now. The machine can call it off first withtilldev secrets call cancel. - Dead-man (
--dead-man, 5 minutes to 30 days): TillDev runs it unless the machine checks in at least that often. The first deadline is--atif you give one, otherwise one interval from now. Eachtilldev secrets call checkinmoves the deadline to one interval from then.
# Delete the droplet at 18:00 unless the job calls it off first.
tilldev secrets presign DELETE "https://api.digitalocean.com/v2/droplets?tag_name=job-42" \
--bearer secretref://infra/prod/DIGITALOCEAN_TOKEN \
--at 2026-10-01T18:00:00Z --expires 12 --quiet > call.token
# On the droplet, to keep it after all:
TILLSECRETS_CALL_TOKEN="$(cat /etc/job/call.token)" tilldev secrets call cancel
# Dead-man: delete it unless the job checks in at least every 30 minutes.
tilldev secrets presign DELETE "https://api.digitalocean.com/v2/droplets?tag_name=job-42" \
--bearer secretref://infra/prod/DIGITALOCEAN_TOKEN \
--dead-man 30m --expires 48 --quiet > call.token
# On the droplet, from the job's loop or a cron entry:
TILLSECRETS_CALL_TOKEN="$(cat /etc/job/call.token)" tilldev secrets call checkin
# Or with curl (add x-tilldev-proof if the call is bound to a machine key):
curl -sf -X POST https://tilldev.dev/api/secrets/broker/call/checkin \
-H "Authorization: Bearer $(cat /etc/job/call.token)"- Once. A timer runs the call at most once, within about a minute of its time, even if the request fails. The result is shown on the call in the broker console and in
tilldev secrets presigned ls, recorded in the audit log aspresign.fire, and emailed to whoever presigned the call. - Same checks. The broker re-checks the destination rules, and you must still be an admin in the org. A timer needs a use left: if the machine already spent the last one itself, nothing runs.
- Held by the token. Check-in and cancel take the same token, address range and machine proof as a run. Once a deadline has passed, the machine can no longer push it back or cancel it; an admin can still revoke the call.
--expires past the last deadline you need; a check-in that lands past it says so. If the scheduler is not running when you presign, the presign is refused rather than handing you a timer that might not fire, and a check-in warns you if it stops later.The broker SDK
The @tillstack/secrets-node and @tillstack/secrets-edge packages ship a first-class broker client so application and agent code can spend references without hand-rolling the wire calls. secretref() builds validated handles; broker.fetch() returns the scrubbed response or a structured refusal — the value never enters your process.
import { createBroker, secretref } from '@tillstack/secrets-node'
// auto mode: uses the LOCAL bridge when running under the TillForge daemon
// (value never leaves the machine), else the cloud broker with your token.
const broker = createBroker({ agentKey: 'billing-job' })
const res = await broker.fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: { authorization: `Bearer ${secretref('payments', 'prod', 'STRIPE_SECRET_KEY')}` },
body: 'amount=2000¤cy=usd',
})
if (!res.ok) throw new Error(`refused: ${res.refusal?.code}`)
// res.body is the scrubbed reply — STRIPE_SECRET_KEY never entered this process.auto mode the Node client routes through the local bridge whenever it runs under the TillForge daemon (the daemon injects TILLDEV_BROKER_URL), so the value is spent on your machine and never reaches the cloud — no code change. Off the bridge, it uses the cloud broker with a session or tp_ API key plus an agent identity (never the ts_ service token — the broker acts on a human's entitlement, not a machine's). An agent should use its own agent token instead: the client reads TILLDEV_AGENT_TOKEN first.Every spend is audited
The broker keeps an append-only spend ledger: one row per attempt — successful or refused — with the reference, destination, agent identity and approving human, but never the value. Review it with tilldev secrets broker spends or in the Secrets console. It is the compliance backbone of “the agent used this key, against this host, at this time, with this consent.”