<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aleksandr Smirnov</title>
    <description>The latest articles on DEV Community by Aleksandr Smirnov (@alxsm).</description>
    <link>https://dev.to/alxsm</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4190105%2F22a787d7-fb13-4b60-bda7-364606bea4c8.jpg</url>
      <title>DEV Community: Aleksandr Smirnov</title>
      <link>https://dev.to/alxsm</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alxsm"/>
    <language>en</language>
    <item>
      <title>How a one-time secret link actually works: the design of SecretBolt</title>
      <dc:creator>Aleksandr Smirnov</dc:creator>
      <pubDate>Sun, 18 Oct 2026 16:49:07 +0000</pubDate>
      <link>https://dev.to/alxsm/how-a-one-time-secret-link-actually-works-the-design-of-secretbolt-4c5o</link>
      <guid>https://dev.to/alxsm/how-a-one-time-secret-link-actually-works-the-design-of-secretbolt-4c5o</guid>
      <description>&lt;p&gt;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 &lt;a href="https://secretbolt.io/?ref=devto" rel="noopener noreferrer"&gt;SecretBolt&lt;/a&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flow
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;SecretBolt returns a link. You send it over any channel.&lt;/li&gt;
&lt;li&gt;The recipient opens the link and clicks &lt;strong&gt;Reveal&lt;/strong&gt;. Once the view limit or the expiry is reached, the secret stops being served and its encrypted content is erased.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; https://secretbolt.io/api/v1/secrets &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"name": "prod-db", "content": "postgres://...", "max_views": 1, "expires_in_hours": 24}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prod-db"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://secretbolt.io/s/&amp;lt;token&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;token&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"expires_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Opening a link doesn't spend a view
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;GET /s/&amp;lt;token&amp;gt;&lt;/code&gt; 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 &lt;code&gt;no-store&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One gap remains: some scanners, Microsoft Defender among them, submit forms themselves in a sandbox. Against those, only a passphrase helps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encryption at rest
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links and passphrases
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gets logged
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The event log isn't the only place a link could leak, so here is what the server logs keep:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The request log replaces the token in every path with &lt;code&gt;[FILTERED]&lt;/code&gt; (&lt;code&gt;Started POST "/s/[FILTERED]/reveal"&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;It doesn't record the client's IP address, and the web servers in front of the app run with request logging off.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Deletion, honestly
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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".&lt;/p&gt;

&lt;h2&gt;
  
  
  Not end-to-end encrypted
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading a secret into your shell
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The export endpoint turns a &lt;code&gt;KEY=value&lt;/code&gt; secret into shell &lt;code&gt;export&lt;/code&gt; 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 &lt;code&gt;PATH&lt;/code&gt; or &lt;code&gt;LD_PRELOAD&lt;/code&gt;) are replaced with comments. That list can't be exhaustive, so only load secrets from people you trust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recommended: write to a private file, read it, then load it&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;umask &lt;/span&gt;077 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; .env.secretbolt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$READ_ONLY_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://secretbolt.io/api/v1/secrets/&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;/export &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Passphrase: &lt;/span&gt;&lt;span class="nv"&gt;$PASSPHRASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;# leave $PASSPHRASE empty if the secret has none&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; .env.secretbolt          &lt;span class="c"&gt;# check what you're about to load&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; ./.env.secretbolt&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt; +a
&lt;span class="nb"&gt;rm&lt;/span&gt; .env.secretbolt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a CI pipeline, drop the &lt;code&gt;cat&lt;/code&gt; line, since it prints the values into the build log. Keep the file out of git.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shortcut: &lt;code&gt;eval&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;secret_env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$READ_ONLY_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://secretbolt.io/api/v1/secrets/&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;/export &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Passphrase: &lt;/span&gt;&lt;span class="nv"&gt;$PASSPHRASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;   &lt;span class="c"&gt;# leave $PASSPHRASE empty if the secret has none&lt;/span&gt;
  &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$secret_env&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is quicker, but &lt;code&gt;eval&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Each fetch uses up one view, so this suits one-off handoffs rather than repeated pipeline reads. Docker needs a different format; see the &lt;a href="https://secretbolt.io/docs?ref=devto#docker" rel="noopener noreferrer"&gt;docs&lt;/a&gt; for &lt;code&gt;docker run&lt;/code&gt;, &lt;code&gt;docker stack&lt;/code&gt; and &lt;code&gt;docker compose&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;You can create a secret on the &lt;a href="https://secretbolt.io/?ref=devto" rel="noopener noreferrer"&gt;homepage&lt;/a&gt; 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.&lt;/p&gt;

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

</description>
      <category>security</category>
      <category>devops</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
