Secure, one-time sharing of passwords, files and links - built on Cloudflare Workers.

(Read instructions at the end of this page!)
| Feature | Details |
|---|---|
| Text secrets | Zero-knowledge credential sharing - AES-256-GCM, Argon2id key derivation, passphrase in URL hash, burn-on-read |
| File sharing | R2-backed, per-file and total caps admin-configurable in Appearance (defaults 9 GB / 9.5 GB, hard ceiling 50 GB), optional password, download limit, server-enforced TTL |
| E2EE file sharing | Opt-in client-side AES-GCM + Argon2id for files up to 150 MiB. Server stores ciphertext only; passphrase travels in the URL fragment (or out-of-band) and never hits the server |
| URL shortener | Short links with TTL and click limit, SSRF-safe, unbiased ID generation |
| Appearance editor | Accent colour, background colour, brand name, tagline, logo, storage limits - all globally persistent |
| Dark / light mode | System-detected per client, manually overridable |
| Drag-and-drop | Full-screen dim overlay on the files tab; drops a file straight into the upload form |
| QR codes | Server-rendered SVG QR on every output link - scan directly from desktop |
| CF Access | All write/admin endpoints protected by Cloudflare Access + RS256 JWT verification |
| Internationalisation | 9 languages, auto-detected per user, flag picker in the UI |
| REST API | Versioned /api/v1/ - admin zone (/api/v1/admin/*) protected by CF Access, public zone (/api/v1/public/*) open; full docs in docs/api.md |
$0 to run. The entire stack - Workers, KV, D1, R2, and Cloudflare Access (up to 50 users) - runs on Cloudflare's free tier. No credit card required, no infrastructure to manage. You only start paying if you exceed the free-tier request limits, which for a self-hosted internal tool is unlikely.
Running a secrets tool on Cloudflare Workers is not just a cost decision - it changes what the tool can actually do.
Globally fast, always. Workers run in 300+ locations worldwide. Whether your recipient is in Warsaw, Singapore, or San Escobar, the secret is served from the nearest edge node - no single-region latency, no cold starts, no load balancers to manage.
No servers, no attack surface. There is no VM to patch, no open SSH port, no container to harden. The entire runtime is ephemeral and managed by Cloudflare. Your only security responsibility is the application code itself.
Native CI/CD integration. Because everything is behind a versioned REST API (/api/v1/), injecting secrets into pipelines is trivial. A GitHub Actions step, a GitLab CI job, or a shell script can push a one-time credential to a recipient without any human in the loop - authenticated via a Cloudflare Access service token, burned on first read which can be used also by machine.
Scales to zero, scales to bursts. Idle periods cost nothing. Traffic spikes are absorbed automatically by Cloudflare's infrastructure - no autoscaling groups, no capacity planning.
Edge-native storage. KV, D1, and R2 are co-located with the Worker. Secret retrieval, file streaming, and metadata lookups all happen without leaving the Cloudflare network.
All UI text is managed in src/i18n.ts - a self-contained module with no external dependencies.
| Code | Language |
|---|---|
en |
English (default) |
pl |
Polski |
de |
Deutsch |
fr |
Français |
es |
Español |
uk |
Українська |
pt |
Português |
zh |
中文 (Simplified) |
cs |
Čeština |
- Open
src/i18n.ts. - Add the new code to the
LangCodeunion type:export type LangCode = 'en' | 'pl' | 'de' | 'fr' | 'es' | 'uk' | 'pt' | 'zh' | 'cs' | 'xx'
- Add a full
Translationsobject under the new key in theI18Nrecord (~95 keys). - Add an entry to
LANG_OPTIONSin the same file:{ code: 'xx', flag: '🇽🇽', name: 'Language name' }
- Deploy - no other files need to change.
Encryption happens entirely in the browser. The server never sees plaintext data or the encryption key.
sequenceDiagram
participant S as Sender
participant Server
participant R as Recipient
S->>S: encrypt(content, passphrase) → ciphertext
S->>Server: store ciphertext + verifier hash
S-->>R: /receive/{id}#passphrase
Note over Server,R: passphrase is in the URL hash - browsers never send it to the server
R->>Server: retrieve (verifier hash only)
Server->>Server: verify → delete (burn on read)
Server-->>R: ciphertext only
R->>R: decrypt(ciphertext, passphrase from URL hash)
What the server knows: encrypted bytes + a password verification hash. What the server never knows: the content, the encryption key, or the passphrase itself.
| Element | Algorithm | Parameters |
|---|---|---|
| Key derivation | Argon2id | m=19 MiB, t=2, p=1, 32-byte output |
| Encryption | AES-GCM | 256-bit, random IV (12 B) |
| Password verifier | Argon2id | Same params, salt id + "_v" |
| Link entropy (with passphrase) | 20-char key, 58-char alphabet | ~118 bits |
Why Argon2id: PBKDF2 is GPU-friendly - a single RTX 4090 can try ~100-200 guesses/s against each stored verifier. Argon2id is memory-hard (19 MiB per hash), which forces attackers to trade GPU parallelism for memory bandwidth and drops the same attack to ~1-10 guesses/s per GPU. It won the 2015 Password Hashing Competition and is the OWASP and NIST default for new systems.
Delivery: the Argon2id WebAssembly implementation (hash-wasm) is bundled into the Worker and served at /ui/argon2.v1.js - same-origin only, immutable cached. No external CDN. The Worker itself never runs Argon2id: key derivation stays client-side, so the ~300-500 ms (desktop) / ~2-3 s (mobile) cost is the browser's, not the Worker's CPU budget.
Versioned verifier: each stored secret carries an algoVersion field in its KV metadata. This lets a future argon2id-v2 (stronger params) roll out without breaking in-flight secrets - the client derives with whatever algo the server says the record was written with.
Files have two independent modes, chosen per-upload via a toggle on /gen → Files:
| Mode | When to use | Trust model |
|---|---|---|
| Normal (default) | Automation, large files up to 50 GB, cases where server-visible content is acceptable | Server has access to the plaintext blob; password (if set) gates retrieval via SHA-256(pwd + salt + PEPPER) |
| End-to-end encrypted | Sensitive content that must never touch the server in cleartext; max 150 MiB | Client encrypts with AES-GCM before upload; server stores ciphertext and an Argon2id verifier; no key material ever reaches the server |
sequenceDiagram
participant U as Uploader
participant W as Worker
participant R2 as R2
participant D1 as D1
participant R as Recipient
U->>W: file + password + TTL + limit
W->>R2: store binary
W->>D1: metadata + salted SHA-256 hash
W-->>U: share link
R->>W: GET /share/:id + password
W->>W: verify salted hash · check TTL + download limit
W-->>R: file stream
W-->>R2: burn when download limit reached
- Optional password hashed as
SHA-256(password + per-file salt + PEPPER)with constant-time comparison — each row gets its own 16-byte random salt so identical passwords across files never collide, and timing cannot leak how close a guess was - Download limit (1×, 5×, or unlimited)
- Server-enforced TTL - maximum 7 days regardless of what the client sends
- Automatic deletion on expiry (hourly cron)
- Lockout after 3 failed password attempts → file deleted immediately. The counter increments atomically (
UPDATE … RETURNING) so parallel guesses cannot race past the 3-try cap - Per-file and total storage caps admin-configurable (defaults 9 GB / 9.5 GB, hard ceiling 50 GB per value) — any value above the 10 GiB Cloudflare R2 free tier requires typing
OKAYto confirm in the Appearance panel - After multipart upload completes the server re-reads the R2 object's actual size and rejects the upload if it does not match the size declared at init — blocks a declare-1-byte-upload-200-MB cap bypass
File passwords are hashed as SHA-256(password + PEPPER), where PEPPER is a global secret stored as a Cloudflare Secret (not in code, not in the repo). Even if the D1 database leaks, the password hashes are useless without the pepper.
flowchart LR
P[User password] --> H[SHA-256]
S[Per-file salt<br/>16 random bytes] --> H
K[PEPPER<br/>Cloudflare Secret] --> H
H --> DB[(Hash stored in D1)]
The Worker refuses to start if PEPPER is not set (bindings guard).
When the uploader flips the End-to-End Encryption toggle on the files tab, the file never leaves the browser unencrypted:
sequenceDiagram
participant U as Uploader (browser)
participant W as Worker
participant R2 as R2
participant D1 as D1
participant R as Recipient (browser)
U->>U: derive Argon2id key + verifier from passphrase
U->>U: AES-GCM encrypt (IV prepended to ciphertext)
U->>W: ciphertext + verifier + algoVersion
W->>R2: store ciphertext blob
W->>D1: metadata + verifier + algoVersion
W-->>U: share link (passphrase in URL fragment)
R->>W: POST verifier candidate (+ Turnstile token)
W->>W: compare verifier · check limit
W-->>R: ciphertext stream
R->>R: AES-GCM decrypt, trigger download
| Element | Algorithm | Parameters |
|---|---|---|
| Key derivation | Argon2id | m=19 MiB, t=2, p=1, 32-byte output |
| Salt | File UUID (for AES key) / UUID + "_v" (for verifier) |
36 bytes |
| Encryption | AES-GCM | 256-bit, random 12-byte IV prepended to the ciphertext blob |
| Verifier | Argon2id output, 32 bytes | Stored base64 in D1, checked via safeCompare |
Constraints and trade-offs:
- 150 MiB hard cap on E2EE uploads. AES-GCM in the browser is one-shot (no streaming), so the full plaintext + ciphertext must coexist in RAM — ~300 MB peak for a 150 MB file, which is the ceiling we can rely on for mid-range mobile.
- Passphrase is irrecoverable. Losing it means the file is permanently unreadable. The server has no way to help — it never sees the passphrase or the key.
- Independent Turnstile toggle.
ui:turnstile_files_e2eein KV can force a challenge on E2EE downloads without touching the normal-files toggle, which is often left off to keep automation working. - Server trust minimised. The server stores only the ciphertext, the Argon2id verifier, and the
algoVersion. A full D1 + R2 leak produces no plaintext.
| Measure | Description |
|---|---|
| Burn-on-read | Secret deleted from KV on first successful retrieval |
| Rate limiting | Max 3 attempts; permanent deletion on lockout (secrets & files). File counter increments atomically (UPDATE … RETURNING) so parallel requests cannot race past the cap |
| TTL preservation | Failed verifier attempts bump the counter but preserve the secret's original expiration — an attacker cannot keep a short-TTL record alive indefinitely by stopping before the 3rd attempt. Secrets with <60 s remaining are burned instead of refreshed |
| Upload size verification | After multipart complete, the server compares the actual R2 object size against the value declared at init and drops the upload on mismatch. Blocks a declared-small / uploaded-large cap bypass |
| Per-file password salt | File passwords hashed with a per-row random 16-byte salt (`SHA-256(pwd |
| Client-encrypted files (E2EE) | Opt-in per upload. AES-GCM + Argon2id client-side, server never sees plaintext or key material. Independent Turnstile toggle (ui:turnstile_files_e2ee) so a managed challenge can be forced on E2EE downloads without breaking automation on the normal flow |
| Global Pepper | File password hashes include a server-side secret; D1 leak doesn't compromise passwords |
| Server-side TTL cap | Backend enforces maximum lifetime; client cannot exceed it |
| CF Access + JWT verification | Protected endpoints guarded at two layers: Cloudflare Access policy + in-Worker RS256 JWT verification against JWKS endpoint (cached 1 h) |
| Security headers | HSTS (1-year, preloadable), X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy no-referrer, Cross-Origin-Opener-Policy same-origin, Cross-Origin-Resource-Policy same-origin, explicit Permissions-Policy denying every permission-gated API we don't use |
| Content-Security-Policy (strict) | No 'unsafe-inline' on script-src, no 'unsafe-eval', no external script origins beyond Cloudflare Turnstile. All page JS is delivered from /ui/app.v1.js (same-origin, bundled). Per-page data (i18n, page context) ships in <script type="application/json"> data islands that the browser stores but never executes. Event handlers use data-action attributes dispatched by a single delegating listener — no onclick= attributes anywhere. A stored XSS (even one that slipped past escapeHtml) cannot execute because inline script is categorically forbidden |
| RFC 5987 filenames | Safe percent-encoded Content-Disposition filenames (no header injection) |
| No content logging | Errors return generic messages - no e.message leakage |
| Bindings guard | Worker returns 500 on startup if any required binding is missing (DB, BUCKET, KV, PEPPER, CF_TEAM_DOMAIN, CF_AUD) |
| Turnstile | Optional Cloudflare Turnstile (managed challenge) on secret retrieval and file downloads - blocks bots and brute-force before any KV/D1/R2 access; token bound to visitor IP via remoteip; failed challenge never increments the attempt counter. See docs/turnstile.md. |
Every release goes through the layered toolchain below before it is cut. Findings from each stage are triaged and either fixed in the same commit, documented as accepted risk, or suppressed with a reasoned .snyk / inline ignore. The live site is re-audited on every push to master.
| Layer | Tool | What it catches | How it's run |
|---|---|---|---|
| Dependency vulnerabilities | Snyk Open Source (SCA) | Known CVEs in hono, hash-wasm, wrangler, qrcode-generator and their transitive deps |
snyk_sca_scan on every commit; currently 0 issues |
| Static code analysis | Snyk Code + Semgrep with p/typescript, p/javascript, p/owasp-top-ten, p/security-audit, p/xss, p/command-injection rulesets |
SAST — insecure patterns, taint analysis, OWASP Top 10 classes | semgrep scan locally before each deploy; currently 0 true positives |
| Architectural & threat-model review | Anthropic Claude Opus 4.7 | Cross-cutting design flaws a rule-based scanner does not see — trust-boundary breaches, endpoint scope / auth gaps, race conditions, protocol misuse between crypto primitives, key / salt reuse, UX flows that silently bypass a control. Used strictly as a reviewer of structure, not as an orchestrator of other tools | Pre-release code walk-through against the full src/index.ts, scoped brief to zero-trust invariants and the E2EE boundary |
| Dynamic scanning (DAST) | OWASP ZAP 2.17.0, seeded with every public endpoint (/receive/:id, /share/:id, /s/:id, /ui/*) |
Active scan: reflected / persistent / DOM XSS, SQL injection, command injection, path traversal, open redirect, CSRF, insecure cookies, CSP / header audit, SRI, HTTP method manipulation | Run against the live production deploy |
| Template-based DAST | Nuclei 3.8.0 with the full public template set (~13k templates) | Known CVEs, exposed files, misconfigurations, default credentials, HTTP header / TLS issues, CSP audit, sensitive-data exposure | nuclei -list <public-endpoints> -severity critical,high,medium,low -exclude-tags dns,tech,intrusive against production |
| CSP audit | Google CSP Evaluator logic reproduced in scripts/csp-check.sh |
Missing hardening directives, 'unsafe-inline', wildcards, unsafe sources |
Part of npm test; fails the suite on any new anti-pattern |
| Header & invariant suite | Custom smoke + KV / D1 invariant scripts (scripts/smoke.sh, scripts/kv-invariants.sh, scripts/d1-invariants.sh) |
Security-header regression, CF Access gating, stored-state shape (algoVersion, failed_attempts bounds, expiresAt canary for TTL preservation) | Part of npm test; run against live production |
Audit scripts and detailed findings live locally (not in the repo) — reach out if you want the artefacts for a specific release.
| Layer | Findings | Outcome |
|---|---|---|
| Snyk Open Source | 0 | — |
| Snyk Code | 0 | — |
| Semgrep | 1 | False positive on the language picker (server-generated HTML from a fixed enum); suppressed |
| Claude Opus architectural review | 5 | All fixed before release: XSS via filename in inline script, non-E2EE password-hash salt absence, TTL slide on failed verifier attempts, failed-attempts counter race, upload size declared-vs-actual mismatch |
| OWASP ZAP 2.17.0 | 1 | Strict-Transport-Security missing on text/plain 404 responses; fixed by the baseline-headers middleware shipped in this release |
| Nuclei 3.8.0 | 0 | — |
| CSP directive audit | 0 | — |
| Header & invariant suite | 0 | — |
All CSP / cookie / header findings reported against / or /robots.txt by the DAST tools originate from the Cloudflare Access challenge page that the Worker redirects unauthenticated admin traffic to. They are out of scope for this release.
flowchart TD
Browser -->|"protected routes<br/>/gen · /api/v1/admin/*"| CFA["Cloudflare Access<br/>JWT RS256 verification"]
Browser -->|"public routes<br/>/receive/:id · /share/:id<br/>/api/v1/public/* · /ui/*"| Worker
CFA -->|verified request| Worker["Cloudflare Worker<br/>Hono / TypeScript"]
Worker --> KV[("KV Store<br/>Encrypted text secrets")]
Worker --> D1[("D1 Database<br/>File metadata")]
Worker --> R2[("R2 Bucket<br/>File binaries")]
| Resource | Usage |
|---|---|
KV (SECRETS_STORE) |
Encrypted text secrets + verifier + algoVersion + expiresAt, short links, global UI config (accent, bg, brand, tagline, storage caps, Turnstile flags) |
D1 (DB) |
File metadata (name, size, TTL, download count, password hash) |
R2 (BUCKET) |
Raw file data (multipart upload, per-file cap admin-configurable up to 50 GB) + logo image |
- Runtime: Cloudflare Workers
- Framework: Hono v4
- Language: TypeScript (strict)
- Deploy tool: Wrangler v4
- QR codes: qrcode-generator - server-side SVG rendering
API endpoints are grouped under /api/v1/ in two zones. Cloudflare Access needs only two rules: /gen and /api/v1/admin/*.
| Method | Path | Description |
|---|---|---|
GET |
/gen |
Secret & upload creation panel |
POST |
/api/v1/admin/secrets |
Save encrypted secret to KV |
GET |
/api/v1/admin/stats |
Storage statistics + file list |
POST |
/api/v1/admin/files/init |
Initiate multipart upload |
PUT |
/api/v1/admin/files/part |
Upload file part |
POST |
/api/v1/admin/files/complete |
Finalize upload |
POST |
/api/v1/admin/links |
Create short link (TTL + click limit) |
POST |
/api/v1/admin/ui/config |
Update global UI settings |
POST |
/api/v1/admin/ui/turnstile |
Update Turnstile settings |
POST |
/api/v1/admin/ui/limits |
Update storage / per-file upload caps (GB) |
POST |
/api/v1/admin/ui/logo |
Upload logo (PNG/SVG/WebP, max 256 KB) |
DELETE |
/api/v1/admin/ui/logo |
Remove logo |
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/public/secrets/:id/retrieve |
Retrieve and burn secret |
POST |
/api/v1/public/files/:id/retrieve-ciphertext |
Verify Argon2id verifier and stream the ciphertext for an E2EE file (client decrypts) |
DELETE |
/api/v1/public/files/:id |
Delete file (uploader self-service) |
| Method | Path | Description |
|---|---|---|
GET |
/receive/:id |
Secret retrieval page |
GET |
/share/:id |
File download / Turnstile gate |
GET |
/s/:id |
Redirect to target URL |
GET |
/ui/config |
Read global UI settings (accent, bg, brand, tagline) |
GET |
/ui/logo |
Serve logo image from R2 |
GET |
/ui/qr |
Generate QR code SVG for a given URL (?d=encodedUrl) |
GET |
/ui/argon2.v1.js |
Serve bundled hash-wasm Argon2id module (immutable, long-cached) |
GET |
/ui/app.v1.js |
Serve the main client application bundle (short-cached; the file that used to live inline in every page) |
Full request/response documentation: docs/api.md
/api/v1/public/files/:id(DELETE) is intentionally outside CF Access - used by the uploader to self-revoke a link./ui/configand/ui/logo(GET) are outside/api/v1/so CF Access policies don't block public clients.
Deploy with one click, then complete the required post-deploy setup below.
-
Create/bind Cloudflare resources:
- KV namespace:
SECRETS_STORE - D1 database:
DB - R2 bucket:
BUCKET
- KV namespace:
-
Initialize D1 schema (table:
files). -
Set required Worker secrets:
PEPPERCF_TEAM_DOMAINCF_AUD- Optional:
TURNSTILE_SECRET
-
Configure Cloudflare Access policies for:
/gen/api/v1/admin/*
-
Keep these routes public (no Access policy):
/api/v1/public/*/ui/config/ui/logo/ui/qr/s/*
-
Deploy and verify:
- Open
/gen(protected) - Open
/receive/:idand/share/:id(public)
- Open
git clone https://github.com/maciekaz/edge-secrets
cd edge-secrets
npm installCopy the example config and fill in your values:
cp wrangler.example.toml wrangler.tomlwrangler.toml is git-ignored - your account ID and resource IDs stay local.
# KV namespace
npx wrangler kv namespace create SECRETS_STORE
# → copy the returned id into wrangler.toml
# D1 database
npx wrangler d1 create secret-db
# → copy the returned database_id into wrangler.toml
# R2 bucket is auto-provisioned on first deploynpx wrangler d1 execute secret-db --remote --command \
"CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
size INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
password_hash TEXT,
max_downloads INTEGER NOT NULL DEFAULT 1,
download_count INTEGER NOT NULL DEFAULT 0,
failed_attempts INTEGER NOT NULL DEFAULT 0
);"None of these go into the repo or wrangler.toml. The Worker won't start without the first three.
# 1. Global pepper for file password hashes (generate a random one)
echo "$(openssl rand -base64 32)" | npx wrangler secret put PEPPER
# 2. Cloudflare Access team domain
npx wrangler secret put CF_TEAM_DOMAIN
# → e.g. yourteam.cloudflareaccess.com
# 3. Application Audience (AUD) tag
# Found at: CF Zero Trust → Access → Applications → (app) → Overview → AUD Tag
npx wrangler secret put CF_AUD
# 4. Turnstile secret key (optional - only if you want bot protection)
# Found at: Cloudflare Dashboard → Turnstile → your site → Secret Key
npx wrangler secret put TURNSTILE_SECRETIf
TURNSTILE_SECRETis not set, Turnstile is silently disabled even if the KV toggles are on - no lockout, no errors.
Make sure you have a CF Zero Trust Access Policy configured for only two paths:
/genand/api/v1/admin/*. Do not include/ui/config,/ui/logo,/ui/qr,/s/, or/api/v1/public/*- these must remain public.
npx wrangler deployCron trigger - the hourly cleanup job (
0 * * * *) is already defined inwrangler.tomland is deployed automatically with the command above. No manual setup needed. It deletes expired files from R2 and D1 every hour. You can verify it was registered under Workers & Pages → your worker → Triggers in the Cloudflare dashboard.
Create a .dev.vars file (git-ignored):
PEPPER=local-pepper-for-testing-only
CF_TEAM_DOMAIN=yourteam.cloudflareaccess.com
CF_AUD=your-aud-tag
# Optional - use Cloudflare's always-pass test key for local Turnstile testing
TURNSTILE_SECRET=1x0000000000000000000000000000000AAIn local dev, requests don't go through CF Access - protected endpoints require a JWT passed manually via the
Cf-Access-Jwt-Assertionheader.
npx wrangler dev
# → http://localhost:8787