← Articles

Stash: a private URL for one HTML file

The itch was specific. I’d generate an HTML thing — a report, a mock, a CLI export, some throwaway document — and want to send it to exactly one person. Every option was too much. A repo and Pages is overkill for a file I’ll forget about in a week. A gist mangles the rendering. Google Docs means an import, a permission modal, and a UI that isn’t mine. I just wanted to point at a file and get back a link I could paste into a message.

Lately that file is usually something a coding harness generated for me, and that’s the other half of the itch. Every harness has grown its own “share this” story — Claude Code’s artifacts are org-only with no public link, Codex Sites is gated behind Sign in with ChatGPT and isn’t even reachable from the CLI I’m sitting in — so whether I can hand someone a link ends up being a property of which tool and which plan tier I happened to be in when the file got made, not a property of the file. A tool that doesn’t care who wrote the HTML or which CLI I ran is the actual fix. That’s why Stash is a script and a Worker instead of a feature I rent from whichever AI shipped best this month.

So the whole tool is one sentence: give it an HTML file, get back a private URL. From my Mac (stash) or from my phone (an iOS Shortcut on the share sheet). No local file becomes a dependency — the R2 bucket is the only source of truth, and if my laptop fell in the ocean the links would still work.

Why Cloudflare

I already live on Cloudflare. jri.me is there, other Workers are there, so there was no new account, no new bill, no new mental model to hold. That alone would have decided it.

R2 having no egress fees is the part that actually matters for this specific job, though. These links stay live indefinitely and I have no idea which one someone might reshare. With most object storage that’s an open-ended bandwidth question I’d have to keep in the back of my head. With R2 it’s just not a variable. Workers bind directly to the bucket, so the whole read path is a few lines: match the key, stream the object, set the headers.

The security is the URL

There’s no login. I didn’t build auth for the reading side at all, and that’s deliberate — it’s a capability URL. The path is a crypto.randomUUID(), which is 122 bits of entropy. You don’t guess that. If you have the link you can see the file; if you don’t, the file may as well not exist for you.

The one real risk with capability URLs isn’t guessing, it’s leaking — and the sneakiest leak is a search crawler finding a link and putting it in an index. So there are two guards against that: every served file gets X-Robots-Tag: noindex, nofollow, and there’s a global robots.txt that disallows everything. Neither of those is an access-control boundary, and I’m not pretending they are. They’re requests that cooperative crawlers honor — and cooperative crawlers are most of the accidental-discovery risk, but not all of it. Non-cooperative scrapers exist, and so do email security scanners that fetch every link you send. So the honest name for this is bearer-link security: having the URL is the authorization, the same way having a key is. What the headers buy is that a leaked link doesn’t get indexed into permanence — the difference between “one stranger saw it once” and “it’s searchable forever.” For files whose worst case is “someone read a report I made,” that’s the right amount of privacy.

There are two quieter leak paths worth naming. Any external link inside a stashed file leaks the URL to its destination through the Referer header — so served files also get Referrer-Policy: no-referrer, which is one more line in the same place the robots header lives. And messaging apps unfurl: paste a Stash link into Slack or iMessage and a preview bot fetches it before your recipient does. That one I accept rather than fight — a preview rendered for the person I sent the link to is the intended audience — but it’s worth knowing the fetch happens.

Writing and deleting do need auth, and here I went the other direction on purpose: a single shared bearer token in the Authorization header. Not per-device, not OAuth, not sessions. There are two clients in the entire world for this thing — my CLI and my Shortcut — both mine. A “proper” auth layer would be more code to maintain guarding a door that only I ever walk through.

But “the right amount of security” only means something if I’m honest about the blast radius, so: everything serves from share.jri.me, its own origin, with nothing else living there — no cookies, no app state, no scripts that matter. A stolen token doesn’t let anyone into jri.me proper; the browser’s origin model does that isolation for free. What it does let an attacker do is host arbitrary HTML — a phishing page, hostile JS — on a domain with my name on it. That’s a real cost, but it’s a reputational one, not a data one. The smaller cost is storage: there’s no upload cap beyond what the platform enforces per request, so a stolen token could also pump junk into R2, and the worst-case bill is bounded by how fast I notice. Both failures have the same recovery — rotate the token, delete the objects, move on — which is most of why I can accept them.

Slugs, and not clobbering myself

Random UUIDs are the default, but sometimes I want a readable path — /changelog instead of a wall of hex. So there’s an optional custom slug. The catch is honesty: a readable slug is guessable in a way a UUID isn’t, so the CLI warns me every time I use one. I’d rather be nagged than quietly hand out a predictable link thinking it’s private.

The subtler problem is collisions. If I publish to a slug that already exists, the naive version silently overwrites whatever was there. I didn’t want a publish to ever destroy an older one by accident. So slug creation is conditional on the R2 etag — the write itself refuses if an object already lives at that key:

const written = await env.BUCKET.put(key, body, {
  onlyIf: { etagDoesNotMatch: "*" }, // create-only
});
if (!written) return new Response("slug exists", { status: 409 });

To actually overwrite I have to mean it: --force. It’s a small precondition check, but it turns “publish” from something that can bite me into something that can only fail loudly.

The part that didn’t go to plan

The original plan had a second trigger: email. Send an HTML file as an attachment to some address, let Cloudflare Email Routing catch it, parse the attachment in a Worker, publish it. Nice for the phone — no Shortcut needed, just forward a file.

Then it collided with reality. This domain already handles my personal email, running on iCloud’s custom-domain support with live MX records pointed at Apple. Cloudflare Email Routing wants the MX records for the zone. You can’t split them — you can’t say “these addresses go to iCloud, these go to a Worker” on the same domain. It’s one set of MX records and one destination.

So the choice was: break my actual email to get a convenience feature, or don’t. I wasn’t going to migrate my mail off iCloud for a publish button. I ended up routing the email receiver through a different domain instead — which works, but it’s the kind of thing that never shows up in the tidy architecture diagram. The diagram says “email → Worker.” The truth is “email, but not on the domain you’d expect, because your inbox got there first.”

There’s a smaller cousin of this in the secrets handling. I keep everything in 1Password with op:// references — that’s my rule across every project. For this token I broke my own rule. op run re-prompts for authorization on every single invocation, and since each publish is a fresh subprocess there’s no session to cache, so the “secure” path was a 1Password prompt every time I wanted to paste a link. For a low-stakes token that only publishes HTML to my own share domain, that friction bought nothing. So it sits as plaintext in a gitignored .env, scoped to this one tool and nothing else. The convention is a good default; this was a spot where following it religiously would have been cargo cult.

A funny coincidence

The day after I finished this, Cloudflare shipped Drop — drag a folder or zip into the browser, get a live workers.dev URL in seconds, no account needed. Same primitive underneath, roughly the same week, which was a good laugh.

It’s not the same tool, though, and the differences are the whole point of each. Drop is anonymous-first: no signup, no auth, multi-file up to 1,000 files, and the site evaporates after an hour unless you sign in and “claim” it. That’s a beautiful answer to “let a stranger throw a site up in ten seconds.” Stash is the opposite set of choices — one file, a private unguessable URL that’s noindexed, permanent until I delete it. And the “zero signup” that Drop advertises I already have for free, because Stash was never anonymous to begin with: it lives in my own account, so there’s no claim step because there was never an ephemeral-stranger step to graduate from. Different problems that happen to share a shape.

Where it landed

The flow now is exactly the one sentence I started with:

stash publish report.html

It uploads the file, prints the URL, copies it to the clipboard, and logs a line to a ledger so I have a record of what I shared even after I delete the object. On the phone it’s a share-sheet tap that hits the same endpoint. list shows what’s currently live, delete revokes a link without me dropping to raw wrangler.

None of it is clever. That’s sort of the point — it does one job, the job I actually had, and the interesting parts were all in deciding what not to build.