Every team has done it: pasted a database password or an API key into Slack "just this once". That message now lives in search, in backups, and on every laptop that synced it. I built SecretBolt to replace those messages with a link that works once and then disappears. This post walks through how it stores a secret, what it logs, how it deletes, and where its limits are.

The flow

  1. You create a secret in the browser or with one API call, and choose how many views it allows and when it expires, within your plan's limits.
  2. SecretBolt returns a link. You send it over any channel.
  3. The recipient opens the link and clicks Reveal. Once the view limit or the expiry is reached, the secret stops being served and its encrypted content is erased.
curl -fsS https://secretbolt.io/api/v1/secrets \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"name": "prod-db", "content": "postgres://...", "max_views": 1, "expires_in_hours": 24}' 
{"name": "prod-db", "url": "https://secretbolt.io/s/<token>", "token": "<token>", "expires_at": "..."} 

Opening a link doesn't spend a view

Slack, Teams and mail scanners fetch every link they see to build a preview. If opening the link returned the secret, a bot would burn it before the recipient ever saw it. So GET /s/<token> only renders a page with a Reveal button. The view is spent by the POST that button sends, and the content comes back in that response, marked no-store.

One gap remains: some scanners, Microsoft Defender among them, submit forms themselves in a sandbox. Against those, only a passphrase helps.

Encryption at rest

Secret content is encrypted with AES-256-GCM through Rails Active Record Encryption, with a fresh random IV every time it is encrypted. GCM is authenticated, so a tampered ciphertext fails to decrypt instead of returning garbage.

The encryption keys live in the app's encrypted credentials. The key that unlocks those credentials is handed to the server at deploy time and is never stored in the repository.

Links and passphrases

Each link carries a random 192-bit token, so guessing one is not a realistic attack. A secret can also have a passphrase, which you send through a different channel. SecretBolt stores only a bcrypt hash of it, and passphrase attempts are rate-limited.

To be precise about what a passphrase does: it is an access check, not an encryption key. It stops someone who intercepted the link. It does not protect against someone who holds both the database and the server's keys. That case is covered below.

What gets logged

Every secret has an event log: created, viewed, expired, destroyed. Views record the viewer's IP address and user agent, so you can tell whether the person you sent it to opened it, or someone else did first. The event log never records the content, the passphrase or the link.

The event log isn't the only place a link could leak, so here is what the server logs keep:

  • The request log replaces the token in every path with [FILTERED] (Started POST "/s/[FILTERED]/reveal").
  • It doesn't record the client's IP address, and the web servers in front of the app run with request logging off.
  • The one exception is a request rejected by rate limiting. It is logged with its IP address, so abuse can be traced, but its path is still masked.

Deletion, honestly

When a secret stops being served (its last view, its owner deleting it, or its expiry), its encrypted content and passphrase hash are erased in the same database write. The database itself rejects a destroyed secret that still holds content, so a code path that forgets to erase fails loudly instead of leaking quietly. An expired secret is never served past its expiry, and its content is erased within 10 minutes of it.

What stays is the metadata (name, view count, timestamps) and the event log, so you can still see who opened the secret. They are permanently deleted 30 days later.

Database backups are kept for 30 days, so ciphertext can remain in a backup for up to 30 days after it was erased. I'd rather state this than promise "gone forever".

Not end-to-end encrypted

SecretBolt is not end-to-end encrypted. The server holds the keys, and while serving a secret it handles the plaintext. That is the trade-off that makes the API useful: a CI job or a script can fetch a secret with a plain HTTP call, with no client-side crypto. If your threat model includes the server operator, use a tool that encrypts in the browser, such as Yopass.

Loading a secret into your shell

The recipient needs their own API key from a free account. Secrets created on the homepage without an account can only be opened in the browser.

The export endpoint turns a KEY=value secret into export KEY='value' lines:

eval "$(curl -fsS https://secretbolt.io/api/v1/secrets/$TOKEN/export \ -H "Authorization: Bearer $READ_ONLY_KEY" \ -H "X-Passphrase: $PASSPHRASE")" 

Leave out X-Passphrase if the secret has none. Every value is single-quoted, so eval only assigns and never runs anything: X=$(rm -rf ~) comes out as export X='$(rm -rf ~)'. Variables that make the shell, the dynamic linker or a language runtime run code, such as PATH, LD_PRELOAD, BASH_ENV or NODE_OPTIONS (in any letter case), are replaced with a # skipped line N comment. The test suite runs the output through a real bash. The list doesn't cover everything: variables like EDITOR or PAGER still change what programs do, so load secrets only from people you trust.

If you'd rather read the output before loading it, save it to a file only you can read:

(umask 077 && curl -fsS -o .env https://secretbolt.io/api/v1/secrets/$TOKEN/export \ -H "Authorization: Bearer $READ_ONLY_KEY") . ./.env 

Keep that file out of git. Each fetch uses up a view, so this suits one-off handoffs, not repeated pipeline reads.

Docker reads env files differently from a shell, so there are separate endpoints that return the same secret ready for docker run --env-file, docker stack and docker compose. See the API docs.

Try it

You can create a secret on the homepage without an account: one view, one-hour expiry. It's tied to no account, and your IP address isn't stored with it. The address is only used to count recent secrets for rate limiting. A free account adds the API, up to 24 hours and 2 views per secret, 10 active secrets, and the event log.

One question I'd really like answered in the comments: would you eval a response from a remote server, and what would it take to convince you it's safe?