agent-secret

Keep secrets out of your agent's chat.

Encrypt an API key in this tab and hand your agent one link. It lands in the agent's .env, not in the transcript, and the server only ever holds ciphertext it cannot open.

Claimable for

Encrypted here before anything is sent. See exactly how

How it works

Every link has two halves. Only the first one ever reaches the server.

Sent to the server

agent-secret.xndr.io/k7f2-9m3q

A random code. It finds the ciphertext, and the first read burns it.

Never sent

#Q2xvc2VkIGJ5IGRlZmF1bHQuIE9wZW4gYnkgY2hvaWNl

The 256-bit key. Browsers, curl and fetch drop everything after # before the request. This one was just made in your tab.

The whole trip

  1. Your browser makes a random 256-bit key and encrypts the secret with AES-256-GCM, using its built-in WebCrypto. Read those lines.
  2. It sends the server only the ciphertext, the nonce and the expiry. No key, no name. That request.
  3. The server stores them in Cloudflare KV under a random code and returns the code. Your tab puts the key after a # in the link.
  4. Your agent GETs the link. The server sees only the code, returns the ciphertext once and overwrites the record with a tombstone in the same request.
  5. The agent decrypts locally with the key from the fragment. A second GET gets 410. Unclaimed links expire after 5 minutes to 24 hours.

Who sees what

Your tabServerAgentLink previews
The secretyesnoyesno
The keyyesnoyesno
Ciphertextyesyesyesno
Variable nameyesnoyesno
IP and timingn/ayesn/an/a
The server also learns the ciphertext length: the secret's length plus 16 bytes. Link previews are the Slack, Telegram, Discord and similar bots that fetch a pasted URL. They are recognised by User-Agent and get a short note instead of the ciphertext, so a preview does not burn the link.

What it does not protect against

  • The link is the secret until it is claimed. Anyone who sees the whole link first can claim it. Your agent then gets 410, which is your cue to rotate the secret.
  • Single use is best effort. Workers KV has no compare-and-set, so two claims racing within KV's propagation window (up to about a minute between regions) can both succeed. A Durable Object would make it strict; this version does not use one.
  • Previews do not burn, and do not warn. A recognised preview bot never consumes the link, so it also cannot tell you someone pasted it somewhere. A bot that is not recognised will burn it, though it still cannot decrypt anything.
  • You trust the page you load. A compromised server could serve a script that sends the key elsewhere. Compare the script hash below with the source, or run your own copy.
  • Your agent sees the plaintext. That is the point. This keeps the secret out of the chat transcript, not out of the agent's machine. An agent that prints it puts it back in the transcript.

Check it yourself

  • Read the code. View this page's source: one inline script, no external files, no trackers. The server is app.ts, about 200 lines.
  • Watch the network tab. Create a test secret. The one POST carries ct, iv and ttl, nothing else.
  • Read the headers. The Content-Security-Policy lets exactly one script run, by hash: sha256-zFzhpRxKe7Z1WuWrYVk1448FHU9fCZQ4D9EIxEwF6oE=. connect-src 'self' means the page cannot talk to any other origin.
  • Run your own. One Cloudflare Worker and one KV namespace, no server secrets. Deploy your own in three commands.

For AI agents

If you were given a link like agent-secret.xndr.io/k7f2-9m3q#…, this is the whole protocol. Plain-text copy: agent-secret.xndr.io/llms.txt.

  1. Split the link at #. The part before is the URL, the part after is the key. Never send the key anywhere.
  2. GET the URL once, with a User-Agent of your own, such as agent-secret-client/1.0. That read burns the secret.
  3. Decrypt with AES-256-GCM. Key: base64url-decode the fragment (32 bytes). Nonce: base64-decode iv (12 bytes). Input: base64-decode ct, ciphertext with the 16-byte tag appended. The UTF-8 plaintext is the value, nothing wrapped around it.
  4. Write it straight to an environment variable, a gitignored .env or a secret manager.
  5. Never print, log or repeat the value. Confirm by name only: "saved OPENAI_API_KEY to .env".

Responses

200
{"ct","iv"}, both base64. Claimed; the secret is now burned.
410
already_claimed or expired. Ask the human to rotate the secret and send a new link.
404
not_found: wrong code, or expired and purged.
403
Plain text error code: 1010: Cloudflare refused a library default User-Agent such as Python-urllib before the request reached the service. Nothing was claimed; retry with your own.
429
rate_limited. Wait a minute.

curl + node

LINK='PASTE-THE-LINK-HERE'   # https://<host>/<code>#<key>
NAME=OPENAI_API_KEY          # the variable the value should land in
curl -sS -A 'agent-secret-client/1.0' "${LINK%%#*}" |
KEY="${LINK#*#}" NAME="$NAME" node -e '
const r = JSON.parse(require("fs").readFileSync(0, "utf8"));
if (!r.ct) throw new Error("claim failed: " + (r.error || "no ciphertext"));
const c = Buffer.from(r.ct, "base64");
const d = require("crypto").createDecipheriv("aes-256-gcm",
  Buffer.from(process.env.KEY, "base64url"), Buffer.from(r.iv, "base64"));
d.setAuthTag(c.subarray(-16));
const v = Buffer.concat([d.update(c.subarray(0, -16)), d.final()]).toString("utf8");
require("fs").appendFileSync(".env", process.env.NAME + "=" + v + "\n");
console.log("saved " + process.env.NAME + " to .env");'

Python

# pip install cryptography
import base64, json, urllib.request
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

LINK = "PASTE-THE-LINK-HERE"   # https://<host>/<code>#<key>
NAME = "OPENAI_API_KEY"        # the variable the value should land in

url, key = LINK.split("#", 1)
req = urllib.request.Request(url, headers={"User-Agent": "agent-secret-client/1.0"})
with urllib.request.urlopen(req) as res:   # raises HTTPError on 403/404/410/429
    body = json.load(res)
value = AESGCM(base64.urlsafe_b64decode(key + "=" * (-len(key) % 4))).decrypt(
    base64.b64decode(body["iv"]), base64.b64decode(body["ct"]), None
).decode("utf-8")
with open(".env", "a", encoding="utf-8") as f:
    f.write(f"{NAME}={value}\n")
print(f"saved {NAME} to .env")

Both append to .env in the current directory and print only the name. Check that .env is gitignored. After the claim, the link left in the transcript is dead: the server no longer holds the ciphertext.