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.
Paste this to your agent
Claimable once, until . Whoever opens the link first gets the secret, so send it only to your agent.
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
- Your browser makes a random 256-bit key and encrypts the secret with AES-256-GCM, using its built-in WebCrypto. Read those lines.
- It sends the server only the ciphertext, the nonce and the expiry. No key, no name. That request.
- 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. - 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.
- 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 tab | Server | Agent | Link previews | |
|---|---|---|---|---|
| The secret | yes | no | yes | no |
| The key | yes | no | yes | no |
| Ciphertext | yes | yes | yes | no |
| Variable name | yes | no | yes | no |
| IP and timing | n/a | yes | n/a | n/a |
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,ivandttl, 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.
- Split the link at
#. The part before is the URL, the part after is the key. Never send the key anywhere. - GET the URL once, with a User-Agent of your own, such as
agent-secret-client/1.0. That read burns the secret. - Decrypt with AES-256-GCM. Key: base64url-decode the fragment (32 bytes). Nonce: base64-decode
iv(12 bytes). Input: base64-decodect, ciphertext with the 16-byte tag appended. The UTF-8 plaintext is the value, nothing wrapped around it. - Write it straight to an environment variable, a gitignored
.envor a secret manager. - 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_claimedorexpired. 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.