The v1 protocol, exactly.
Everything below is written against frozen, published code. Every constant can be checked in the repository, and the cross-implementation test vectors pin the byte-level behavior. If this page and the code ever disagree, the code is the truth and the disagreement is a bug worth reporting.
The short version
- The note text is encrypted on the sender's device. No server ever sees it.
- The decryption key travels only in the link fragment, the part after
#, which browsers never send to any server. - Decryption happens in the recipient's browser, in JavaScript anyone can read with View Source.
- The management token behind burn, expiry, and status can control a note but can never decrypt it. The server stores only its SHA-256 hash.
- The full link cannot be rebuilt after the created-note screen closes. The app keeps only the note id and the token, never the key.
The link
burnpony.app/n/{id}#{key}{flags}. The id is 22 base64url characters minted by the server from 16 random bytes. The key is 43 characters of unpadded base64url encoding exactly 32 random bytes, generated on the sender's device. The optional flags suffix is a period plus letters: p means a passphrase is required, r means the sender enabled a read receipt, so the page can tell you both things before the view-consuming reveal. Everything after # is a URL fragment, and browsers do not send fragments in requests. The key never reaches any server, appears in no log, and is not part of the stored note. That one property carries the whole design.
The envelope
What the server stores is one opaque JSON string: {"v":1,"pw":false,"salt":"…","nonce":"…","ct":"…"} — a format version, the passphrase flag, a 16-byte salt, a 12-byte nonce, and the AES-256-GCM ciphertext with its 16-byte authentication tag appended, all standard base64. Key order in the envelope is not significant; it is parsed as JSON.
The payload, byte for byte
The plaintext under the encryption is {"v":1,"t":"<text>","ah":<seconds>} — the note (up to 50,000 characters) and the auto-hide timer. Its serialization is deterministic across every implementation: keys in exactly that order, no whitespace, escaping limited to \" \\ \n \r \t \b \f plus \u00XX for remaining control characters, everything else emitted literally as UTF-8. The Swift, WebCrypto, and Python reference implementations produce identical bytes, and the shared vectors enforce it.
Key derivation
The encryption key is HKDF-SHA256(ikm, salt, info = "BurnPony-v1-key", 32 bytes). Without a passphrase, ikm is the fragment key. With one, the passphrase is stretched by PBKDF2-HMAC-SHA256 at 600,000 iterations over the same salt and appended to the fragment key first. Passphrases are canonicalized before stretching in every implementation: Unicode NFC normalization, then trimming of leading and trailing whitespace including U+FEFF. A stray keyboard space or decomposed Unicode input is forgiven; capitalization and interior spacing changes deliberately are not. A wrong passphrase and tampered ciphertext both simply fail GCM authentication; nothing distinguishes the two.
The API
Six operations under /api, all JSON. POST /notes stores an envelope with a view cap (1–100), an expiry (300 to 2,592,000 seconds), and the receipt flag, returning the note id and a one-time 64-hex management token. GET /notes/{id} consumes one view inside a transaction and nulls the ciphertext when the cap is reached. With the management token: GET …/status reports counters and receipt times, DELETE burns immediately, PATCH re-times a living note counted from now, and POST /notes/{id}/push registers up to five APNs or FCM tokens for receipt delivery. POST /report/{id} files an abuse report without consuming a view. The token is stored server-side only as a SHA-256 hash, and every failure that could distinguish a wrong token from a burned note from a note that never existed returns the byte-identical {"error":"not_found"} — the constancy is deliberate.
What the server stores
Ciphertext (nulled on burn or exhaustion), view counters, timestamps, the token hash, receipt timestamps, push tokens for receipt notes, hashed-with-a-local-secret rate-limit counters, and abuse reports. No accounts, no emails, no raw IP addresses, no analytics. A sweeper deletes expired rows, but expiry is also enforced at read time, so a dead cron never extends a note's life.
Read receipts, mechanically
Receipts are disclosed to the recipient before they reveal — that is what the r flag exists for. The push payload is deliberately generic: the alert says only that a note was opened, and the sole custom key is the note id. The sender's own device rewrites the notification locally using a label that exists nowhere but that device; the server never knows any label.
Verify it yourself
Three checks, no trust required. One: the recipient page is reproducible — scripts/assemble_viewer.py --verify in the repository proves the shipped viewer.html is byte-identical to its three auditable parts, and View Source on any live note lets you compare against the published file. Two: the cryptography is pinned — shared/burnpony_vectors.json holds ground truth from an independent implementation, and both the Swift test suite and the embedded WebCrypto core reproduce every ciphertext exactly. Three: the server's poverty is readable — server/schema.sql and the API router are short enough to read in one sitting, and there is nothing in them to leak. The honest limits stand regardless: a revealed note can always be copied or photographed, and the operator could serve different code than published — which is precisely why the page is reproducible and this specification exists, so substitution is detectable.
The friendlier tour of the same machinery is in the documentation; the security model and reporting path are on the security page.