cipher.ink Write a message

How cipher.ink works

One message followed from the first keystroke to the moment it's gone: what happens, where, and what the server can see.

cipher.ink encrypts your message in your browser before anything leaves your device, stores only the locked version on the server, and lets the recipient's browser unlock it. This page follows one message from the first keystroke to the moment it's gone, and says at each step what happens, where it happens and what the server can and can't see. It's the long version. The short one is on the front page, and the strictest one, with the limits, is the security page.

The one-paragraph version

You write. Your browser cleans up the formatting, makes an encryption key (from a passphrase you and the reader both know, or at random and carried in the link), and seals the message with AES-256-GCM. It uploads the sealed result and nothing else, and the server hands back an address. You send the reader the address, and the key travels with it in the link's # part, which browsers never send to a server, or separately as a passphrase. The reader's browser downloads the sealed message, builds the same key and unseals it. When the message burns, is destroyed or expires, the server deletes the sealed text at once and keeps only a few timestamps for a while.

Step 1: you write

The front page is a document editor. You type into it, apply formatting with the toolbar or markdown shortcuts, and attach files if you want to. Nothing is sent while you type. The text exists only in the page, with no drafts saved on the server and no autosave, and the page even warns you before you close the tab with unsealed text in it. Anything you paste goes through the same sanitizer that runs again when the message is read. That matters later, because the reader's browser will display your HTML and must never be able to run a script. The editor's source is editor.js, one of the files on the annotated source page.

Step 2: your browser seals it

in your browser: plaintext exists only here 1. Writein the editor 2. SanitiseDOMPurify 3. SealAES-256-GCM 4. PackageJSON payload key from a passphrase (PBKDF2, 600,000 rounds, random salt) or 32 random bytes that stay in the link's #fragment a fresh random 12-byte IV for the message and for every file 5. Uploadciphertext only the servernever gets the key Nothing is sent while you type, and nothing readable is ever sent.
Where each step happens. The shaded area is your browser.

When you press Seal it, the page does four things in order.

  1. It chooses a key. In Passphrase mode, the passphrase you typed (or the dice button made, 5 words from a list of 288, plus a number from 10 to 99) is stretched into a key with PBKDF2-HMAC-SHA256 at 600,000 iterations and a fresh random 16-byte salt (why 600,000). In Secret link mode, the key is 32 random bytes drawn from the browser's secure random generator, and no passphrase exists.
  2. It seals the message with AES-256-GCM. A fresh random 12 bytes (96 bits) IV is drawn, and the message goes through AES in Galois/Counter Mode, which both hides it and appends a 128-bit tag that makes any later change, or a wrong key, detectable (AES-GCM, explained). What gets sealed is more than the HTML: the time, a random receipt token, and the name, type, size and IV of each attachment go inside too.
  3. It seals the attachments, if any: up to 10 files, 5 MB in all, each with the same key and its own fresh IV. Their names and types are never sent in the clear.
  4. It packages the result as a small JSON object, the payload, and uploads it with the encrypted files and a few settings you chose (the mode, burn or not, an expiry, and an optional hint).

What the payload really looks like

Here's a real payload made by the page's own code (scripts/examples/payload-example.mjs) for a 107-byte message, sealed with a throwaway passphrase, with the ciphertext shortened:

A real payload, 250 bytes in all

{
 "v": 2,                               // format version
 "m": "p",                             // mode: p = passphrase, k = secret link
 "it": 600000,                         // PBKDF2 iterations (passphrase mode only)
 "s": "Dw1OsSi9h8h0ORRGrprXtA",        // the random salt, base64url (passphrase mode only)
 "n": "OcZL9h7o1LlRW35z",              // the random 12-byte IV, base64url
 "c": "k_mIHjcXj5O2nsv0xZyahW6M…"      // ciphertext + 16-byte tag, base64url (164 characters here)
}

The ciphertext field is 123 bytes: the 107 bytes of sealed JSON plus the 16-byte tag, so encryption added exactly the tag and nothing else. Everything in the payload besides c is non-secret by design, since the salt and IV only need to be unique. In secret-link mode there's no it or s, because there's no passphrase to stretch. Inside c, once it's decrypted, sits another small object: the sanitized HTML, the time, the receipt token and any attachment details. The server never sees that object.

Step 3: the server stores ciphertext

The upload goes to api.php, which checks that it's a well-formed payload of an acceptable size (up to about 700 KB of ciphertext, around 500 KB of text) and that the connection hasn't made more than 30 seals this hour. It then generates a message id of 12 characters from a 57-letter alphabet (about 70 bits, hard to guess), stores the row and returns the id. What the server keeps for a message is the ciphertext, the mode, the burn flag, the expiry time, the number and encrypted size of attachments, when it was sealed, and SHA-256 of the receipt token (so it can check a token but not read it). One thing is in the clear: the passphrase hint, if you wrote one. It's shown on the lock screen to anyone with the link, and the page says so. The full table of what the server sees is on the security page.

The page now shows you a sealed card with the link, and what goes in the link depends on the mode:

  • Passphrase mode: the link is just https://cipher.ink/m/<id>, and it reveals nothing. You tell the reader the passphrase by another route (say it on a call), so someone who intercepts the link alone gets a locked box and no key.
  • Secret link mode: the link is https://cipher.ink/m/<id>#<key>. The key is in the fragment, the part after the #, which a browser removes before it sends the request. So the server sees the id and never the key (proof, with a real request). Whoever has the whole link can open the message, so send it carefully, which is what burn after reading is for.

The card also shows a private read-receipt link. Keep it, and you can see when the message is opened or destroy it. It's saved in My messages in your own browser (and nowhere else, never on the server), and it works the same way, with the secret token living in that link's fragment.

Step 5: the reader unseals it

The reader opens the link and sees a lock card. The page has the message's settings (whether it needs a passphrase, any hint, how many attachments, when it expires) but, for a burn message, not yet the ciphertext. They enter the passphrase, or the page uses the key it found in the fragment, and press Unseal. Now the reader's browser does the reverse of step 2. It asks the server for the sealed payload if it doesn't have it, rebuilds the key (running the same PBKDF2 for the same 600,000 rounds, if there's a passphrase) and decrypts. If the key is wrong, or one bit of the ciphertext was altered, the GCM tag doesn't match and nothing is shown, which is exactly how a wrong passphrase looks. If it matches, the message is sanitized again and displayed, and its attachments are fetched and decrypted. Only now, because only now has the decryption succeeded, does the page tell the server the message was read, using the token that was sealed inside it. A link-preview bot can't decrypt, so it can't send that, which is why it never counts as a read.

The step in view.js that does all this is short enough to read in full, and it's on the code page.

Step 6: the message ends

live sealed and stored burned destroyed expired first reader opens a burn message someone with the token presses Destroy expiry time passes (checked on read) tombstonetimestamps, no content deletedafter 90 days The moment a message ends, its ciphertext, hint and (except for 15 minutes after a burn) its files are wiped.
The life of a message.

A message ends in one of three ways, and in each the server wipes the ciphertext and hint at once:

  • Burned. If burn after reading was on, the server marks the message burned in the same instant it hands the ciphertext to the first reader. A second reader arriving a moment later is told it's gone (how that works).
  • Destroyed. Anyone holding the token, which means the sender with the receipt link and anyone who has decrypted the message, can press Destroy it now.
  • Expired. Every message has an expiry: 10 minutes, 1 hour, 1 day, 1 week (the default), 30 days, or never. The server checks it whenever the message is touched and, once it has passed, ends the message right then. There's no scheduled job, so an overdue message is unreadable the moment anyone asks for it.

After a message ends, the server keeps a tombstone: the id, the mode, the burn flag, the timestamps for sealing, first opening and ending, the number of opens, and the token hash. There's no content in it. It exists so the sender's receipt can say "Opened and burned" or "Expired unread", and it's deleted 90 days later. A burned message's attachments remain fetchable for 15 minutes, once each, so the reader's page can download them after unsealing.

What we deliberately leave out

  • No accounts and no passwords of ours. Nobody signs up. Your list of sent messages is kept in your own browser.
  • No cookies (0) and no trackers, analytics or ads. There's no third-party code of any kind, and the Content-Security-Policy makes the browser enforce that: 0 requests to other sites. The feedback tab loads from feedback.cipher.ink, a server I also run.
  • No email. The one exception is the contact form, which isn't encrypted, says so on the page, and goes only to the person who runs the site.
  • No build step. The scripts are served as written, unminified, 147 KB in all on the front page, so you can read them.

The old messages

cipher.ink first appeared in 2016, and we rebuilt the site in October 2026. Messages sealed on the earlier version still open with their original passphrase: that's the 81 old messages in a separate table, read-only, decrypted by a different path (how we still open them). The old format was weaker, and nothing is ever written in it now.

Where it can still go wrong

Encryption in the browser means you trust the server to send honest JavaScript, which is true of every web-based encryption tool (client-side encryption, honestly). A shared passphrase is only as safe as the way you share it, and a secret link is as private as wherever you paste it. The hint isn't encrypted. Metadata such as the size of a message, and when it was sealed and opened, is visible to the server. And once a recipient has read a message, they can copy it. The security page lists these without euphemism, and the FAQ answers the questions people ask most.

Further reading

  1. NIST SP 800-38D: GCM and GMAC
  2. RFC 8018: PKCS #5 (PBKDF2)
  3. RFC 3986: URI generic syntax, section 3.5 Fragment
  4. MDN: Web Crypto API