cipher.ink Write a message

cipher.ink security: what the server sees, and what it never does

The exact promise, the table of what the server keeps, how hostile HTML is handled, and the known edges, stated plainly.

The promise is narrow and exact: the server stores ciphertext and never receives anything that would decrypt it. This page says what that rests on, what the server does see, and where the edges are. It's the public twin of the project's internal security document, and the two change together. If you'd rather have the design as a story, read how it works. If you want to check a claim, each one below has a section of the annotated source behind it.

How a message is sealed

Passphrase modeSecret-link mode
KeyPBKDF2-HMAC-SHA256, 600,000 iterations, a random 16-byte salt, over the passphrase in Unicode NFC form32 random bytes from the browser's secure random generator
Where the key livesin the reader's head; you tell themin the link's # fragment
CipherAES-256-GCM through the browser's Web Crypto API, a fresh random 12 bytes (96 bits) IV, a 128-bit tag

The payload the server stores is a small piece of JSON: a format version, the mode, the PBKDF2 settings and salt (passphrase mode only), the IV and the ciphertext. Everything in it except the ciphertext is non-secret. Inside the ciphertext are the sanitized message HTML, the time it was sealed, a random receipt token, and the name, type, size and IV of each attachment. Attachments are encrypted with the same key and their own IVs, then uploaded as raw bytes, so their names exist only inside the sealed message.

A secret link's key and a receipt link's token both sit in the URL fragment. Browsers never send a fragment to a server, so neither shows up in a request or an access log (demonstrated with a real request). Pages also send Referrer-Policy: no-referrer.

A wrong passphrase is detected, not guessed at. GCM's authentication tag fails to verify, and nothing gets rendered from a failed decryption (AES-GCM, explained).

What the server sees and keeps

The server seesKept
the payload (ciphertext) and the encrypted attachment bytesuntil the message ends (burn, destroy, expiry), then wiped at once
the mode (passphrase or secret link), the burn flag, the expiry, the number of attachments and their encrypted sizeswith the message; mode, burn, expiry and count also in the tombstone
the passphrase hint, in plain textuntil the message ends, then wiped with the payload
a SHA-256 hash of the receipt tokenwith the message and the tombstone
when it was sealed and first opened, how many times it was opened, and when it ended90 days after it ends (the tombstone)
the visitor's IP address, as a salted hash, in hourly buckets, to limit sealing to 30 messages an hour2 days
the IP address, time and URL path of every request (never the # part)in the web host's Apache logs, outside the application's control
the contact form's text, and an optional reply address, in plain textnot stored by the site: emailed at once to the person who runs it through Mailgun, so it sits in Mailgun's logs and that person's mailbox

The server never sees the message text, a file name, the passphrase, a secret link's key or the receipt token itself. The contact form is the one exception to "nothing readable reaches the server", and the form says so in bold. It also tells people that a report only needs the /m/<id> part of a link, and that they should leave off the key unless they want the message read.

Rendering untrusted HTML

A message is HTML written by whoever sealed it, so we treat it as hostile, in three layers (rendering HTML you do not trust):

  1. A sanitizer. DOMPurify 3.1.6, 22 allowed tags, 1 attribute (href), with an allowlist of tags and only the href attribute. Links must be http(s): or mailto: and open in a new tab with noopener noreferrer nofollow. It runs on paste, on seal and again on render.
  2. A Content-Security-Policy. Scripts, styles, fonts and images load only from this site, connections go only to this site, and inline script isn't allowed at all. Even HTML that slipped past the sanitizer couldn't run (the policy, line by line).
  3. Safe attachment handling. Only PNG, JPEG, GIF, WebP and AVIF display inline, from an in-memory blob: URL. Everything else, SVG included, is offered as a download and never opened in the page.

The test suite seals a message containing an <img onerror>, a <script> and a javascript: link, and fails unless none of it survives or runs.

Burn after reading

When someone opens a burn message, the server ends it with a single conditional database update. If two people open it at the same instant, only the one whose update lands gets the ciphertext. The ciphertext of a burn message is never in the page, so a link preview or scanner can't burn it. Only pressing Unseal and burn does (the full account). A burned message's attachments can be fetched once each, within 15 minutes, and they're deleted as they're served. If the reader then types the passphrase wrong, the server's copy is already gone, the ciphertext lives only in that browser tab, and the page says so. The count happens when the server hands the message over, before anyone knows whether the passphrase is right. That's why the receipt says Opened and burned and not read.

Limits

  • 30 seals and 60 MB per connection per hour. The 31st gets a "too many" answer. Only sealing is limited.
  • A message tops out at about 700 KB of ciphertext (around 500 KB of text). Attachments are limited to 10 files and 5 MB in total.
  • The contact form allows 5 messages per connection per hour and 20 an hour from everyone together. It has a hidden trap field for bots and mails one fixed address, so it can't be used to send mail to anyone else.

Known edges

These are the places where the promise stops. A security page that hides them is advertising, so here they are.

  • You trust the server to serve honest JavaScript. That's true of every web-based encryption tool: a compromised or coerced server could send a page that leaks what you type. Everything is self-hosted under a strict policy, which narrows the ways to do it, and the code is small and readable, but nothing removes the need to trust the host (client-side encryption, and what would reduce this).
  • The passphrase hint is plain text, by design, and the page says so.
  • "My messages" lives in the sender's browser: message ids, tokens, links (a secret link includes its key) and a 48-character label from the start of each message. Anyone using that browser profile can open them, and the drawer tells people to clear it on a shared computer.
  • Holding the token is enough to destroy a message and read its status. Readers get the token by decrypting, so a reader can destroy what they were sent. That's intended.
  • Metadata is visible to whoever controls the database: timestamps, open counts, mode, sizes and, while the message is live, the hint. It stays for up to 90 days after a message ends.
  • Rate limiting is per connection only. There's no CAPTCHA and no account system.
  • Passphrase strength is the sender's choice. The meter is advice. Slowing guesses down with PBKDF2 can't save a weak passphrase on a message that isn't set to burn, because anyone with the link can download its ciphertext and attack it offline. The crack-time estimator shows how much strength is enough.
  • A reader can copy what they read. No tool can stop a screenshot.
  • Messages from 2016 to 2026 use a weaker format (details). They're read-only, and nothing new is written in it.

Reporting a problem

To report a vulnerability, or a message that shouldn't be here, use the contact form. A report about a message needs only its /m/ address, not the part after the #. The same address is published in /.well-known/security.txt. The contact form isn't encrypted, so don't put a secret in it.

This page is the public version of docs/SECURITY.md in the project, and when one changes the other is updated in the same change.

Further reading

  1. OWASP Top Ten: Cross-Site Scripting
  2. RFC 9116: A File Format to Aid in Security Vulnerability Disclosure
  3. NIST SP 800-38D: GCM and GMAC