Notes from production
RustCloudflareWebAssembly

Building ZeroPaste: Rust + WASM on Cloudflare

We needed a fast way to share a single secret — a password, an API key, a token — over a one-time link, without having to trust the server that stores it. The result is ZeroPaste: an end-to-end encrypted, burn-on-read pastebin, written entirely in Rust → WebAssembly on a single Cloudflare Worker.

This article walks the technical decisions that shaped it: why we moved from Go to Rust, why one Cloudflare Worker with KV and a Durable Object turned out to be the right fit, and then the full deploy — Cloudflare and GitHub — including the permissions you need.

The ZeroPaste web app at zeropaste.pangaea.id: a dark, minimal compose screen with a large text area for the secret, a button to create the one-time link, and a quiet running counter of secrets sealed.
The ZeroPaste compose screen, live at zeropaste.pangaea.id — encrypt in the browser, get a one-time link.

End-to-end encryption: the key lives in the link, not the server

The whole product is one sentence: the server can never read your paste.

When you create a paste, your browser generates a random AES-256 key and encrypts the text with AES-256-GCM via the browser's native Web Crypto API — before anything is sent. The decryption key is placed in the link after the #:

https://zeropaste.pangaea.id/#[link-id]:[key]

The trick is the #. The part after # (the URL fragment) is never sent by the browser to the server in an HTTP request. So the server only receives the link-id (to look up the blob) and opaque ciphertext — never the plaintext, never the key. That's what makes it zero-knowledge: even if our database is seized or subpoenaed, all that's there is random data we cannot decrypt.

One important consequence: because the key is in the link, anyone holding the full link can read that paste once. The stored ciphertext is useless without the key half.

How ZeroPaste works, end to end: the creator writes a secret; it is encrypted in the browser with an AES-256-GCM key; only the ciphertext is stored on zeropaste.pangaea.id under a link id; the link (#id:key) is sent to the reader; the reader fetches the ciphertext by id and decrypts it locally with the key to reveal the secret.
The full path, animated from the live site: encrypted by the creator, stored as ciphertext, decrypted by the reader — the key travels only in the link's # fragment, never to the server.

The decision: Go → Rust

The first plan used Go (TinyGo → WASM). We switched to Rust after weighing three things — and all three lean Rust for a crypto-centric product like this.

  • crypto/aes and crypto/cipher fail TinyGo's own test suite — gambling self-rolled AES on an unverified implementation is the wrong call for a security tool.
  • The Worker SDK for Go is a third-party community project.
  • A Durable Object must be written in JavaScript — its class can't be Go.

  • The aes-gcm crate runs correctly in WASM — but we still call native crypto.subtle (audited, fast), so there's no compromise either way.
  • workers-rs is the first-party Cloudflare SDK.
  • A Durable Object can be written in Rust (#[durable_object]) — no JavaScript creeps in.

The third point decided it: we needed a Durable Object for one thing (next section), and in Go that forces a slice of JavaScript into a project that was meant to be "Rust only." Rust makes the whole stack one language — Rust on the backend (the worker), Rust on the frontend (the UI + the crypto.subtle caller), both compiled to WebAssembly.

Why a Worker + KV + one Durable Object

Storage looks trivial: store a blob, hand it back once, delete it. But one requirement forces an architecture choice — an accurate paste counter ("N secrets sealed" on the page).

We use Workers KV for the ciphertext: it's a global key-value store, perfect for stashing an opaque blob and handing it back. But KV is bad at counters:

  • KV has no atomic increment. The only way is read-modify-write (read N, write N+1). Two concurrent pastes both read N and both write N+1 — one increment is lost.
  • KV is eventually consistent and guides ~1 write/sec per hot key. A single global counter key is the worst case for KV.

A Durable Object (DO) solves it. One DO instance is serialized by its runtime — only one request runs at a time inside it — so tokens += 1 then storage.put is atomic with no locking, and durable (it survives restarts, unlike an in-memory cache). That's the exact counter KV can't give you.

It matters that SQLite-backed DOs are available on Cloudflare's free plan — so the atomic counter and the rate limiter add no cost.

Burn-on-read, click-to-reveal, and the prefetch trap

A paste disappears at whichever comes first: read once (burn-on-read), or expired at a 7-day backstop TTL if never opened. KV reaps the key server-side — no cleanup cron, and no orphaned pastes pile up.

But "read once" has a real-world trap. Messaging apps (Slack, WhatsApp, iMessage) and email scanners prefetch URLs to build a preview. If the page fetches the paste automatically on load, a preview bot would burn the paste before the human ever reads it.

The fix: click-to-reveal. Opening a #id:key link shows a "Reveal & burn" gate; the GET /api/:id request (which burns the paste) only fires when the user clicks. A prefetch bot that doesn't click can't burn it. We verified this in production: loading the page makes 0 /api calls; clicking makes exactly one.

Configuration: all via env

Every behavior you might want to change lives in wrangler.toml [vars] — edit, then redeploy:

[vars]
PASTE_TTL = "604800"      # burn-on-read backstop, seconds (7 days)
COUNT_CACHE_TTL = "60"    # per-isolate counter read-cache, seconds
RL_BURST = "10"           # rate-limit token bucket capacity per IP
RL_REFILL_SEC = "6"       # seconds to regenerate 1 token (~10/min)
MIN_CHARS = "3"           # minimum length (FE + server validation)
CANARY_SINCE = "30 June 2026"   # warrant canary date
CONTACT_EMAIL = "[email protected]"

A GET /config endpoint returns JSON {min_chars, canary, contact}; the frontend injects the canary date and contact email from it, and security.txt is generated by the worker from CONTACT_EMAIL. One source of truth.

Deploying it: Cloudflare + GitHub Actions

The build isn't trivial: there are two WASM artifacts — the worker (via worker-build, run automatically by wrangler deploy) and the frontend (via wasm-pack). make deploy chains both. We chose GitHub Actions + a token (not Cloudflare's built-in Git integration) because Actions gives full control over the Rust+WASM toolchain.

git push origin mainGitHub Actions: install Rust + wasm32, wasm-pack, worker-buildcargo test (core logic) → make build (frontend wasm)wrangler deploy: compile the worker, apply DO migrations, upload assetsLive at zeropaste.pangaea.id
One push to main → release.

One-time Cloudflare setup:

  1. KV namespacewrangler kv namespace create PASTES, then paste the id into wrangler.toml.
  2. Durable Objects — nothing to pre-create; the [[migrations]] (new_sqlite_classes) apply automatically on the first deploy.
  3. Custom domain — a [[routes]] block with custom_domain = true for zeropaste.pangaea.id; Cloudflare manages the DNS record + TLS cert.

The API token permissions you need. This is a Workers tool, not Pages — pick the wrong product and the deploy fails. The quickest route is Cloudflare's "Edit Cloudflare Workers" API-token template (My Profile → API Tokens → Create Token). If you build a custom token instead, it needs all of these — miss one and wrangler deploy errors out partway:

Account · Workers Scripts ............ Edit   deploy the worker + its Durable Object classes
Account · Workers KV Storage ......... Edit   read/write the PASTES namespace
Account · Account Settings ........... Read   wrangler resolves which account to use
Zone    · Workers Routes ............. Edit   bind the worker to zeropaste.pangaea.id
Zone    · DNS ........................ Edit   create the custom-domain record
Zone    · SSL and Certificates ....... Edit   issue the cert for the custom domain
User    · User Details ............... Read   token verification / wrangler whoami

Account Resources:  Include · <the account that owns zeropaste>
Zone Resources:     Include · pangaea.id

Two things people miss: Durable Objects need no separate permissionWorkers Scripts · Edit already covers deploying their classes and applying migrations; and the three Zone rows (Routes, DNS, SSL) exist only for the custom domain — drop them if you ship to the free *.workers.dev subdomain instead.

Do

  • Start from the "Edit Cloudflare Workers" template, then add DNS · Edit and SSL and Certificates · Edit on the pangaea.id zone for the custom domain.
  • Scope Account Resources and Zone Resources to the one account / one zone that owns it.

Don't

  • Use a Cloudflare Pages token — it can't deploy a Worker (different product, different scope).
  • Reach for the Global API Key — it's account-wide; a narrow token limits the blast radius if it leaks.

One-time GitHub setup:

  1. Repository secrets — add CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID under Settings → Secrets and variables → Actions → New repository secret.
  2. Let Actions open the canary PR — Settings → Actions → General → Workflow permissions → tick "Allow GitHub Actions to create and approve pull requests." Without it, the weekly warrant-canary workflow has the permission to commit but can't open its PR, and the job fails.

After that, a push to main deploys itself.

The bottom line

ZeroPaste is small, but every piece is a decision: Rust so the crypto is reliable and the Durable Object stays one language; Worker + KV for opaque ciphertext; one Durable Object for the counter KV can't make atomic; click-to-reveal so preview bots don't burn your secret; and GitHub Actions so one push ships the whole thing. What makes it trustworthy isn't a promise — it's a fact you can watch in the Network tab: the key never leaves your browser.

Why not Cloudflare's built-in Git integration instead of GitHub Actions?

The build needs two separate Rust→WASM builds (the worker via worker-build, the frontend via wasm-pack). GitHub Actions gives full control to install that toolchain; Cloudflare's built-in build runner can, but it's fiddlier for the Rust+WASM toolchain. So Actions is the better fit here.

If the key is in the URL, isn't it leaked in browser history?

The # fragment is stored in your own browser history — but it's never sent to the server, never in server logs, and never in a cross-site Referer header. The risk is local (your device), not on the host. And the paste is burn-on-read: once it's read, the link is useless anyway.

Is the counter really atomic on the free plan?

Yes. SQLite-backed Durable Objects are available on Cloudflare's free plan, and the DO runtime serializes requests to one instance — so the increment is atomic with no locking. That's exactly why we use a DO for the counter, not KV.

Sources

  1. workers-rs — Rust bindings for Cloudflare Workers
  2. Cloudflare — Durable Objects
  3. MDN — SubtleCrypto (Web Crypto)
  4. Cloudflare — Workers KV