Security model
What we can see, what we can't, and why.
Plain language first, then the exact construction, matching packages/crypto/src/v2.ts. If this page and the code ever disagree, the code is right and this page is a bug.
The short version
What our server can see
- Ciphertext, and how big it is.
- A hash it uses to check that a reader holds the key.
- Expiry, read limits, and when a link was read.
- IP addresses and request times, through Cloudflare and the web app's host.
- For chat: when messages arrive, and how many people are in a room.
- If you ask to be notified when a link opens: your browser's push address.
- If you use the remote Claude connector: the text you share, in memory while it's encrypted.
What it can't see
- Your text, files, file names, chat messages or replies to requests.
- The key. It lives after the # in the link, which browsers don't send.
- Your password, if you use one. It's stretched in your browser.
- Request labels and nicknames, which are encrypted too.
- Which device a link was opened on, beyond what an IP address says.
The catch, stated up front: this is a web app, so you trust the JavaScript we serve each time you load it. If our server were compromised and sent you modified code, that code could read what you type. The code is open source, and you can run your own copy if that trust is the problem.
Specification
Keys and passwords
Every share is sealed with a key derived from input keying material (ikm) and a fresh 16-byte random salt. Where the ikm comes from depends on the mode:
A key-mode link is the whole secret: 256 bits of randomness, nothing to guess. Password mode is only as strong as the password. Someone with a copy of the database could test guesses against the stored hash at their own pace. Against the server itself, guessing is slowed: past 10 wrong attempts in a minute, a password or key+password note is locked for 10 minutes, and while it's locked even the right password is refused (see rate limits). Each guess also costs 600,000 PBKDF2 rounds. Prefer the link key, or the link key plus a password, and use a generated password if you use one at all.
Encryption
encKey = HKDF-SHA256(ikm, salt, info = "ps/v2/enc") → AES-256-GCM key
iv = 12 random bytes, new for every encryption
aad = "ps/v2/{kdf}" (notes and EnvShare)
blob = AES-256-GCM(encKey, iv, plaintext, aad)The mode is bound in as additional authenticated data, so a ciphertext sealed as key+password can't be relabelled as key to skip the password. GCM's tag means any changed byte fails decryption instead of producing garbage. Keys are derived as non-extractable CryptoKeys. Binary values travel as base64url without padding.
GCM doesn't pad, so the ciphertext is 16 bytes longer than your text. The server can tell a 12-character password from a 4 KB .env file.
Proving you hold the key
authToken = HKDF-SHA256(ikm, salt, info = "ps/v2/auth") 32 bytes authHash = SHA-256(authToken) stored by the server
Reading a share is two requests. First GET /api/notes/:id/meta returns the salt and mode, and no ciphertext. Your browser derives the token and sends it in an X-Auth-Token header with the real request. The server hashes it, compares in constant time, and only then returns the ciphertext. A burn-after-reading note is deleted in that same step.
The token and the encryption key come out of HKDF with different labels, so the token, or its hash, gives no help decrypting anything. What it does do: keep people who only know a share id from downloading the ciphertext or burning the note.
Links made here also get a 32-byte manage token, kept in My links in the creating browser. The server stores its SHA-256. The token itself is sent, in X-Manage-Token, only when you check a receipt (status, read count, first and last read times) or revoke the link. Receipts never contain content.
The link fragment
https://protectedshare.me/notes/x7Kp2Qa9Lm#k=Rk3n9Vx2Lq7Tz4Yb8Wm1Pc6Hd0Js5Ga2Fe9Ku3No7
Everything after # is the fragment. Browsers don't put it in the HTTP request or in the Referer header, so neither our server nor Cloudflare receives it. The page reads it with JavaScript. Reader pages then clear it from the address bar with history.replaceState, so it isn't left on screen or in a URL you copy afterwards.
The fragment can still turn up in places that are yours, not ours:
- the browser history of the device that opened the link,
- the chat or email you used to send it,
- any browser extension that can read the page.
Fragment fields by tool: #k= for notes, EnvShare, file drops and request labels; #p= for a request's private inbox key; #r=…&k=… for chat invites. The part before the fragment is the share id, which the server does see. Note and EnvShare ids are 10 random characters; links made before v2 have 5.
File drops
- Keys
- A new 32-byte key in
#k=and a 16-byte salt per drop, then the same HKDF split into an encryption key and an auth token. - Chunks
- Files are read in 1 MiB chunks. Each chunk is encrypted on its own with a fresh IV and uploaded as iv ‖ ciphertext.
- Storage
- Encrypted chunks are stored in Cloudflare R2 as
drops/{id}/{index}. D1 keeps each chunk's size, and an upload can never store more than the total declared when the drop was created. A self-hosted API without an R2 bucket keeps the chunks in D1. - Chunk binding
- Each chunk's AAD is
ps-chunk:{index}/{total}, so chunks can't be reordered, swapped, dropped or added without failing to decrypt. - Manifest
- File names, types and sizes go in a JSON manifest encrypted with the same key. The server never sees a file name.
- Uploads
- Chunks are accepted only with an upload token whose hash the server stores. Unfinished drops are deleted after 6 hours.
- Limits
- 25 MiB of files per drop, at most 40 chunks, 1–100 downloads, expiry up to 30 days.
- Downloads
- Fetching the manifest needs the auth token, and doesn't use a download. Starting a download asks the server to take one download off the count, in a single atomic update, and to open a download session valid for 60 minutes. The download counts from that moment, and cancelling doesn't give it back.
- Chunks out
- Every chunk request needs the auth token and that session's token in
X-Download-Token. A retry or resume reuses the same session. With no downloads left, no new session can start. - Deletion
- When the count reaches zero, the drop and its chunks, in D1 and R2, are deleted as soon as the last open session finishes, or by the cleanup job once every session has expired. Revoking, a takedown and expiry delete them too, and the cleanup job also removes any R2 chunk whose drop is gone.
Secret requests
A request lets someone send you a secret without either of you sharing a password first. It uses public-key encryption, so only your browser can open the reply.
requester (pub, priv) = ECDH P-256 key pair
server gets pub (65-byte raw); priv (PKCS#8) lives only in the inbox link #p=
sender (epk, esk) = fresh ECDH P-256 key pair
shared = ECDH(esk, pub)
binding = epk ‖ pub
key = HKDF-SHA256(shared, salt16, info = "ps/v2/request" ‖ binding)
reply = AES-256-GCM(key, iv, secret, aad = binding)
requester k = 32 random bytes, only in the sender link #k=
label = AES-256-GCM(HKDF(k, "ps/v2/label"), { title?, note?, pk: SHA-256(pub) })
respondTok = HKDF-SHA256(k, zero salt, info = "ps/v2/respond") server stores SHA-256
inboxToken = HKDF-SHA256(SHA-256(priv), zero salt, info = "ps/v2/inbox")Both public keys are bound into the key derivation and the ciphertext, so a reply can't be moved to another request. Every request carries an encrypted label, under a separate key in the sender link's #k=. It holds the optional title and note (“AWS root password”) and always a SHA-256 fingerprint of the requester's public key.
Only someone holding the full sender link can answer: posting a reply needs the respond token, which is derived from that #k=. Before sending, the sender's browser decrypts the label and checks the fingerprint against the public key the server returned. If a server swapped in its own key, the check fails and the page refuses to send. One reply is accepted; collecting it with the inbox token deletes the request.
The limit that remains is the one described above: a server that serves modified JavaScript could skip the check. Self-hosting removes that trust.
Chat rooms
- Room keys
- New rooms use a 32-byte room key in the invite link
#r=…&k=…. Password rooms stretch the password with PBKDF2-SHA256 at 600,000 iterations, salted with the first 16 bytes ofSHA-256("ps/v2/room:{roomId}"), so every member derives the same keys with no server round trip. - Messages
- Encrypted with AES-256-GCM under the room's HKDF encryption key, a fresh IV per message. Nicknames, replies, reactions and images travel inside the ciphertext.
- Room auth
- The first member claims the room with the hash of an HKDF auth token. After that, every room endpoint needs the token, including the live stream.
- Safety words
- Five words derived from
HKDF(ikm, roomSalt, "ps/v2/safety"). Compare them on another channel: if they match, you derived the same key. - Deleting
- Each message can carry the hash of a random delete token held by its sender. The server deletes a message only when shown that token.
- Disappearing
- Per-message expiry from 5 seconds to 7 days, enforced by the server when reading and by the purge job.
- Burn room
- Deletes every message, the presence rows and the room record at once.
- Limits
- 30 joins and 120 messages a minute per room. More than 10 wrong keys or passwords in a minute lock the room for 10 minutes. The server can't tell a password room from a key room, so both kinds lock.
- Purge on empty
- A member stops counting as present when they leave, or 15 seconds after their last heartbeat. Stale presence rows, and the messages of a room nobody is present in, are deleted on the room's next request or by the 30-minute cleanup job, whichever comes first. Claimed rooms are forgotten after 7 idle days.
The server sees message sizes and timing, and a count of who is online and typing. Presence is a random client id, a typing flag and a last-seen time.
Notepad and free tools
The notepad uses the original v1 scheme: AES-256-GCM with a key from PBKDF2-SHA256 at 210,000 iterations. Local mode keeps the vault in your browser. Synced mode uploads ciphertext, stores your username as a SHA-256 hash, and authorizes saves with a password proof (described under v1 below). A note can be exported as a self-decrypting HTML file that opens offline.
Encrypt text seals text in password mode (600,000 PBKDF2 iterations) into a block between -----BEGIN PROTECTEDSHARE MESSAGE----- lines, carrying salt, IV and ciphertext. It makes no network request. The password generator draws from crypto.getRandomValues with rejection sampling, so no character is more likely than another.
The breached-password checker is the one free tool that makes a request. It hashes the password with SHA-1 in the tab and sends only the first 5 hex characters to Have I Been Pwned's range API (api.pwnedpasswords.com/range/), with Add-Padding: true, no cookies and no referrer. The service answers with every hash suffix sharing that prefix, padded so the reply's size doesn't hint at the prefix, and the match is made in the browser. That page alone adds the host to its Content-Security-Policy. The other tools make no network request.
Older links (v1)
Links created before this version carry a password after # with no k=. They still open:
- Encryption
- AES-256-GCM, key from PBKDF2-SHA256 at 210,000 iterations with a random salt.
- Read check
- A password proof: SHA-256 of PBKDF2-SHA256(password, fixed salt
protectedshare-proof-salt-v1, 100,000 iterations), sent as?proof=.
The fixed salt makes the proof the same for the same password everywhere, and a query string is more likely to be logged than a header. That's why v2 replaced it. New links never use it. Since shares last at most 30 days, the remaining v1 notes and secrets expire on their own; the check stays only for them and for notepad workspaces.
Rate limits and locks
The API counts requests with Cloudflare's Workers rate limiting. Counts are kept per Cloudflare location and are eventually consistent, so they're approximate: they stop floods and online guessing, not the odd extra request. Over a limit the API answers 429 with a Retry-After header.
| Limit | Counted per | Per minute |
|---|---|---|
| Wrong keys or passwords | Note, secret, file drop, request, chat room or notepad | 10 |
| Joining a chat room | Room | 30 |
| Answers to a secret request | Request (only the first is accepted) | 10 |
| Chat messages | Room | 120 |
| Abuse reports | Reported link. Report emails to us are capped at 5 a minute per Cloudflare location. | 5 |
| Creating notes, secrets, drops, requests and notepads; reports; contact messages | Client IP address, IPv6 grouped by /64 | 600 |
Past the wrong-attempt limit, anything a password protects locks for 10 minutes: password and key+password notes and secrets, v1 links, chat rooms (the server can't tell a password room from a key room, so both lock) and synced notepads. While locked, every attempt is refused, right or wrong, so someone who knows the id can keep it shut for 10 minutes at a time. That is the price of stopping guessing. Key-only links, file drops and requests never lock: their keys are random 256-bit values, so only the wrong attempts get a 429 and the real recipient is never locked out.
A lock is one row in auth_lockouts: auth:{kind}:{id} and the time it ends (a notepad's id is the SHA-256 of its username). No IP address, nothing about who tried. The cleanup job deletes it once it has passed.
The create limit keys on the CF-Connecting-IP address that Cloudflare sets. It goes to the rate limiter and nowhere else; the code doesn't write it to D1. On protectedshare.me, API calls reach Cloudflare through the web app's host, so that address is usually the host's, shared by many visitors. That is why the limit is high, and also its weakness: someone who sends that many creates in a minute holds up creates for everyone else behind the same host address until the minute is up. Every limiter is optional: a self-hosted API without one skips that check.
Read notifications
Optional, per link, from My links. The server sends a standard Web Push message and learns nothing beyond which browser to reach.
- Turning it on
- The browser subscribes with the server's VAPID public key and sends the subscription to
POST /api/manage/:kind/:id/watchwith the link'sX-Manage-Token. Only an active link can be watched, by at most 5 browsers. - Stored
- Per browser: the push endpoint, the subscription's P-256 public key (
p256dh) and 16-byte auth secret, the link's kind and id, and a timestamp. No content, no key, no IP address, no account. - Push services
- Endpoints must be https on a browser push service: Google (fcm.googleapis.com), Mozilla, Apple or Microsoft. Anything else is refused, at sign-up and again at send time, and redirects aren't followed.
- When
- On the first counted open (for a request, when the answer arrives) and on the read or download that uses the link up. Sent after the response, so a read never waits on a push service.
- Message
- Encrypted for that browser alone (RFC 8291,
aes128gcm):{ kind, id, event, at }, padded to 128 bytes so its length says nothing about the link. The push service may hold it for up to a day (TTL: 86400). - Server identity
- VAPID (RFC 8292): an ES256-signed token for that push service's origin, valid for 12 hours, plus our public key.
- What the push service learns
- That our server, identified by its VAPID key, sent this browser a message, when, and its padded size. Not what it says. The service worker adds your label from this browser's storage.
- Deleted
- Right after the used-up notification; when the link is revoked or taken down; when its receipt is deleted (both by database trigger); when the push service answers 404 or 410; and by the cleanup job once the link has expired. Turning it off in My links removes it for every browser.
- In the browser
- One service worker,
/sw.js, scoped to/share-target/, so it controls no app page and caches nothing. It is registered only when you turn notifications on, or by the installed app. IndexedDBps-notifyholds each watched link's kind, id, label and expiry. Never the URL, key or manage token.
Abuse reports
- Reporting
- Pages that open a note, secret, file drop or request send
POST /api/reportswith the link's kind and id, taken from the page route and never from the address bar, a reason, optional details (up to 500 characters) and an optional email. - No keys
- Text containing a link key (
#k=,#p=) is refused before anything is stored, by the form and again by the server. - Stored
abuse_reports: kind, id, reason, details, contact email and time. No IP address. Deleted after 180 days.- When the operator sets
RESEND_API_KEY, each report is emailed through Resend to the address inNOTIFY_EMAIL_TO(admin@protectedshare.me unless the operator sets another), with every field HTML-escaped. Every report is stored either way. - Takedown
DELETE /api/admin/:kind/:idwith anX-Admin-Tokenheader, compared in constant time with a Worker secret of at least 32 characters. Without that secret the route answers 404. It deletes the content and any R2 chunks, and marks an active receipt revoked, which also ends its notifications.
We still can't read the content, so a takedown goes on the report and the id.
Claude connector (MCP)
The same links can be made from Claude or any MCP client, two ways. Both use the v2 code above and store exactly what the website stores. Setup is on the Claude integration page.
| Remote connector | Local server | |
|---|---|---|
| Runs | https://mcp.protectedshare.me/mcp, the Worker protectedshare-mcp on Cloudflare, reached directly (not through the web host). No sign-in. | npx protectedshare-mcp on your computer, over stdio. |
| Encrypts | In the Worker's memory. The text arrives over TLS, is sealed with AES-256-GCM under a fresh link key, and is never stored or logged. | On your computer, before anything is sent. |
| Passwords | No. Links carry their key only. | Optional: PBKDF2-SHA256, 600,000 iterations. |
| To the storage API | Ciphertext and hashes to create; kind, id and manage token to check or revoke. Over a Service Binding (Worker to Worker, no public hop). | Ciphertext and hashes to create; the id and a derived token to open; kind, id and manage token to check or revoke. Over HTTPS, with the user agent protectedshare-mcp/<version>. |
| Tools | create_secret_link, share_env_file, request_secret, check_link_status, revoke_link, generate_password. | The same, plus open_secret_link and check_password_breach. |
| Logs | Request metadata only: method, path, status, timing. A rejected request logs its error class name. Never arguments, results or bodies. | Nothing on disk. stderr names an error's type, nothing more. |
Link keys, request key pairs and manage tokens are generated where the tool runs and returned in the tool's result; nothing keeps a copy, so these links don't appear in My links. That result, like everything else in the conversation, is visible to the MCP client and its AI provider. The local open_secret_link spends a read exactly as a browser would, and labels the text it returns as written by the link's creator, not as instructions. check_password_breach sends only a 5-character SHA-1 prefix to Have I Been Pwned, from your computer. generate_password makes the password where the server runs (in the Worker, for the remote connector) and stores nothing.
Both servers use the official MCP TypeScript SDK (@modelcontextprotocol/server 2.3.1, pinned) and speak MCP 2026-07-28 plus 2025-11-25, 2025-06-18 and 2025-03-26. The Worker accepts POST only, answers in JSON, keeps no sessions, reads at most 512 KB of a request, checks the protocol headers against the body, refuses an Origin other than claude.ai, claude.com or localhost with a 403, takes one JSON-RPC message per request (batches get a 400), and allows 600 requests a minute per connecting IP (an IPv6 address counts by its /64), of which 120 may call the tools that create links. Inputs are strictly validated: notes and EnvShare files up to 50,000 characters remotely, which keeps encryption inside a Worker's CPU budget. API calls time out after 25 seconds, and error messages never repeat what you sent.
Threat model
Protects you against
- Someone with a copy of our database or server reading your content. They get ciphertext and hashes.
- Network observers. Traffic is TLS, the content is already encrypted, and the key isn't in any request.
- Link previews burning a note. Unfurlers fetch without the fragment, so they can't produce the read token, and the note page waits for a click.
- Fast online guessing. Wrong keys and passwords are limited per link, and anything a password protects locks for 10 minutes after more than 10 misses in a minute.
- Us reading what you send from the website or the local MCP server, in normal operation.
Does not protect against
- A compromised device or browser, or a malicious extension on either end.
- Anyone who gets the full link. It is the key. Use a separate password if the channel worries you.
- A malicious server serving modified JavaScript. Every in-browser encryption app has to trust the code it loads. Mitigation: open source, and self-hosting.
- Metadata: Cloudflare, the web app's host and we can see IP addresses, timing, sizes, and which ids are read. A push service sees when a read notification is sent.
- A weak password in password mode. Online guessing is slowed, not stopped, and a copy of the database can be guessed against offline.
- The remote MCP connector seeing your plaintext. It has to: it encrypts on our server. For end-to-end encryption, use the website or the local MCP server.
- Your MCP client and its AI provider seeing the conversation, including the text you ask it to share and the links and keys the tools return.
- Someone who knows a password-protected link's id keeping it locked, and its recipient out, 10 minutes at a time.
- The recipient keeping a copy once it's decrypted.
Retention
A job runs every 30 minutes to delete expired rows. Between runs, expired shares are already refused: every read checks the expiry.
Hosting and logs
The hosted API is a Cloudflare Worker with a D1 database, and encrypted file-drop chunks live in a Cloudflare R2 bucket. On protectedshare.me your browser calls it directly, at api.protectedshare.me, so Cloudflare sees IP addresses, request metadata and the ciphertext passing through, and the web app's host sees page loads. A self-hosted web app can instead relay /api requests to the Worker, and then its host sees them too. Nobody sees a key, which never leaves the fragment. Workers observability logging is on for the API: it records request metadata and the errors the code logs, and the code doesn't log request bodies.
The remote MCP connector is a separate Worker, protectedshare-mcp, at mcp.protectedshare.me. Clients reach it directly, so the web app's host isn't in that path, and it calls the API over a Service Binding. Its observability logs hold request metadata only.
Google Analytics and Cloudflare Web Analytics each load only when the operator sets their id or token at build time; a self-hosted copy loads neither unless you set one. Even then, neither loads on pages that open or hold a share (note, EnvShare and file links, requests and their inboxes, chat, My links, the notepad, the share target), and either one that loaded earlier is switched off while such a page is open. Google Analytics page views report the path only, never the query string or the fragment. The Cloudflare beacon is cookieless, strips the query string and fragment itself, and counts full page loads only.
The contact form stores the name, email, company and message you send, so we can reply. Contact messages and abuse reports are emailed through Resend when the operator sets RESEND_API_KEY, and read notifications travel through the browser's push service.
Browser protections
Every page is served with a Content-Security-Policy. Scripts, styles, fonts, workers and connections are limited to this site (plus the hosts of Google's or Cloudflare's analytics, only when that analytics is on and never on pages that open or hold a share; the breached-password checker's page also allows api.pwnedpasswords.com). Images may also come from data: and blob: URLs, which is how decrypted previews are shown. object-src and base-uri are 'none', forms can only post here, and frame-ancestors 'none' with X-Frame-Options: DENY stops other sites from framing the app.
Stated honestly: the policy still allows inline scripts ('unsafe-inline'), because the framework's page bootstrap is inline and we don't use per-request nonces. So the policy doesn't stop injected inline script from running. What it does is keep script from loading off other hosts and block fetches, images and form posts to other hosts, which are the usual ways to send a key or plaintext out of the page. Pages also send X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, HSTS, and a Permissions-Policy that turns off camera, microphone, geolocation and topics.
Report a vulnerability
Email admin@protectedshare.me with what you found and how to reproduce it. Please give us a chance to fix it before you publish. For anything that isn't exploitable, a GitHub issue is fine.
