DEV Community

Cover image for How a one-time secret link actually works: the design of SecretBolt
Aleksandr Smirnov
Aleksandr Smirnov

Posted on Edited on

How a one-time secret link actually works: the design of SecretBolt

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}'
Enter fullscreen mode Exit fullscreen mode
{"name": "prod-db", "url": "https://secretbolt.io/s/<token>", "token": "<token>", "expires_at": "..."}
Enter fullscreen mode Exit fullscreen mode

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

To fetch a secret from a script, the recipient needs a read-only 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 shell export lines. Every value is single-quoted, so loading it only assigns variables and never runs embedded commands. Variables that can make the shell, the dynamic linker or a language runtime execute code (such as PATH or LD_PRELOAD) are replaced with comments. That list can't be exhaustive, so only load secrets from people you trust.

Recommended: write to a private file, read it, then load it

(umask 077 && curl -fsS -o .env.secretbolt \
  -H "Authorization: Bearer $READ_ONLY_KEY" \
  https://secretbolt.io/api/v1/secrets/$TOKEN/export \
  -H "X-Passphrase: $PASSPHRASE")   # leave $PASSPHRASE empty if the secret has none
cat .env.secretbolt          # check what you're about to load
set -a; . ./.env.secretbolt; set +a
rm .env.secretbolt
Enter fullscreen mode Exit fullscreen mode

In a CI pipeline, drop the cat line, since it prints the values into the build log. Keep the file out of git.

Shortcut: eval

secret_env=$(curl -fsS \
  -H "Authorization: Bearer $READ_ONLY_KEY" \
  https://secretbolt.io/api/v1/secrets/$TOKEN/export \
  -H "X-Passphrase: $PASSPHRASE") &&   # leave $PASSPHRASE empty if the secret has none
  eval "$secret_env"
Enter fullscreen mode Exit fullscreen mode

This is quicker, but eval means trusting every server response without looking at it. Thanks to a reader in the comments for pushing me to make the file route the default.

Each fetch uses up one view, so this suits one-off handoffs rather than repeated pipeline reads. Docker needs a different format; see the docs for docker run, docker stack and docker compose.

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?

Top comments (4)

Collapse
 
pushpendra2011 profile image
Pushpendra Yadav •

To your question: no, I wouldn’t eval it. Not because the quoting looks wrong (single-quoting plus the denylist is careful), but because eval means trusting your parser and your server on every future request, and I can’t verify the next response the way I can verify this post. The umask 077 file route is the one I’d use, since I can read it before sourcing it. I’d make that the documented default and keep eval as the shortcut.

One idea for the “not end-to-end” trade-off: an optional mode with the key in the URL fragment (/s/#), decrypted in the browser. Fragments never reach the server, so your request logs, link-preview bots and anyone holding the database can’t read the secret. API users who want plain HTTP can skip it, and browser users get E2E without needing a separate tool.

Collapse
 
alxsm profile image
Aleksandr Smirnov •

Quick follow-up: this is now live. The docs, the homepage and the comparison pages all lead with the file route (umask 077, read it, then set -a; . ./.env.secretbolt; set +a, then delete it), and eval moved below it as a labelled shortcut. The eval version also now saves the response first and only runs eval if curl succeeded. I've updated the example in this post too. Thanks for pushing on it; the fragment-key mode is on the roadmap and I'll share the design write-up here when it's ready.

Collapse
 
pushpendra2011 profile image
Pushpendra Yadav •

Nice, that was quick! The new flow reads much better with the file route first. And yes, happy to review the fragment-key write-up when it’s ready. You’re right about still trusting the served JS. Keeping the decrypt page tiny and static, with no third-party scripts, would at least make it easy for people to check what’s running.

Collapse
 
alxsm profile image
Aleksandr Smirnov •

Thanks, this is exactly the kind of feedback I was hoping for.

On eval: you've convinced me. The point isn't whether today's quoting is right, it's that eval asks you to trust every future response sight unseen. I'll make the file route the documented default and keep eval as an explicitly labelled shortcut.

On the fragment key: I like it a lot. A browser-only mode where the key lives after the # and decryption happens client-side would keep the plaintext away from the server, request logs, preview bots and the database, while API users keep plain HTTP. The trade-off I'd want to be upfront about is that you still trust the JavaScript the server sends you, so it narrows the trust rather than removing it. I'm adding it to the roadmap and will write up the design before building it. Would you be up for reviewing that write-up when it's ready?