cipher.ink Write a message

Learn · Technologies we use

Read receipts without tracking pixels

We count a read only when a message is decrypted, using a token hidden inside it. What that records, what it can't, and how pixels differ.

cipher.ink can tell you when a message you sent has been opened, and how many times. It does that with no tracking pixel, no record of who opened it, and nothing learned about the reader's device. Here's how, set against the thing it replaces: the invisible image email senders use to find out whether you read their mail.

How an email tracking pixel works

A marketing email usually carries a tiny image, often one pixel square and transparent, served from the sender's server at an address unique to you, like https://track.example/p/8f3a2c.gif. You never see it. When your mail program shows the message, it fetches the image, and that request tells the sender this specific address was opened, at what time, from which IP address (a rough location, and often an employer or provider), and with which software. Click-tracking links work the same way, with a redirect in place of an image. None of it needs your consent, and a normal reader can't tell it's there.

It's also less reliable than it looks. Mail programs that block remote images hide the open entirely, and some providers fetch images ahead of time, so a "read" gets recorded when no person looked. Apple's Mail Privacy Protection, introduced with iOS 15 and macOS Monterey in 2021, pre-fetches and caches a message's images through Apple's servers whether or not the recipient opens it. That hides the recipient's IP address and inflates open counts. Marketers now treat open rates as a weak signal.

The problem for a message like ours

A cipher.ink link gets shared in a chat, an email, a text. Any of those may fetch the page before a human does, to build a link preview. Messaging apps, mail scanners, antivirus gateways and search crawlers all request the URLs they're given. If "the page was loaded" counted as a read, every link would show as read the moment someone pasted it into a chat. And a receipt built on a pixel would need an image on the page, which the Content-Security-Policy here forbids, and which would tell some other server who's reading.

What a read receipt is here

A read is counted only when someone successfully decrypts the message. This is the whole mechanism, from compose.js, view.js and api.php:

  1. A token is made when you seal. Your browser draws a random token, 18 bytes (144 bits), and puts it inside the message, in a field of the JSON that then gets encrypted. The token is part of the secret.
  2. The server learns only its hash. Alongside the ciphertext, the page uploads sha256(token). The server stores that and can't get the token back from it (SHA-256 is one-way).
  3. The reader decrypts. Only someone with the passphrase or the secret link can, and the browser knows it worked because the authentication tag verified. The token comes out of the plaintext.
  4. The page reports the open. It sends the message id and the token to the server. The server hashes the token and compares it with the stored hash. On a match it adds one to the count and records the time of the first open.

A link-preview bot can't decrypt, so it never learns the token, and its request can't be turned into a count. A scanner that loads the page and runs its script still lacks the key, which sits after the # and never reaches the server (why the part after # never reaches the server). Nobody outside can fake a count, because nobody outside has the token. (As with any receipt, you trust the server to count honestly. It couldn't invent a reader's token, but it could raise a number. What it can't do is read the message.) Counting hangs on the one thing only a real reader can do.

The sender sees the result on a private receipt page, reached through a link whose token sits after a #, so the token never appears in a request line or a server log. The page shows one of: still waiting, opened (with the time of the first open and a count), burned, expired, or destroyed. The same token lets whoever holds it destroy the message, which is why we call the page private.

What isn't collected

An open leaves two numbers in a database row: the count of opens and the time of the first. There's no reader identity, no IP address stored with the open, no user agent, no language, no location, and no list of when each later open happened. The rate limiter keeps a salted-hash count of seals per connection (sealing only, never opening) for two days. After a message ends, the server keeps a tombstone of timestamps and counts for 90 days, and no content.

One caveat belongs here. The hosting provider's web server keeps its own access log (the IP, the time and the URL path of every request, though never the part after #), and that's outside this application's control. The application never reads it and never ties it to a message's opens. The security page lists everything the server sees.

Limits you should know about

  • A receipt means the message was decrypted, not that a particular person read it. The link could have been forwarded, and a count of 3 may be one reader coming back twice.
  • The receipt depends on the reader's page. The page the reader's browser runs sends the report. Someone who decrypts the message with their own software, or blocks the request, goes uncounted. It's a courtesy from an honest page, not a surveillance system.
  • Your own previews don't count. If you open a message from the browser that sealed it (its id is in My messages), the page reports no open, so the sender can't fool the receipt.
  • Burn messages are counted differently. A burn-after-reading message is counted when the server hands it over, before anyone can know the passphrase is right, because the handover is the burn. So the receipt says Opened and burned, not read (burn after reading).
  • It won't tell you when a message was read most recently, or who read it. That's by design.

Comparing the two

Email tracking pixelcipher.ink receipt
What triggers itShowing the message, or a pre-fetchSuccessfully decrypting it
Fooled by scanners and previewsYesNo (they cannot decrypt)
What the sender learnsTime, IP address, device, location, every openTime of first open and a count
Who is identifiedThis recipient, by unique URLNobody
Extra request to another siteYes, to the sender's serverNo, to this site only
Can an outsider fake itYes: anyone can request the imageNo: they lack the token, which is inside the encrypted message

How cipher.ink uses this

The token is 18 bytes (144 bits), drawn from getRandomValues (random numbers in the browser) and written in base64url (base64). The server keeps only its SHA-256, in the column del of the message row, and the same token authorizes the receipt page and the destroy button. A finished message keeps its timestamps for 90 days. The browser side is in the code, and the server's side is two small functions in api.php.

Further reading

  1. Apple: Mail Privacy Protection (iOS 15, 2021)
  2. RFC 3986: Uniform Resource Identifier (URI): Generic Syntax, section 3.5, Fragment
  3. MDN: Referrer-Policy