All posts

Deleting is not the same as forgetting

Almost every service that promises to delete your data is telling you about one DELETE statement and quietly not telling you about six copies.

"Permanently deleted" is the load-bearing claim of any burn-after-reading service. It is also the one most often made carelessly, because running the DELETE is trivial and everything that makes it true is not.

Where a deleted row still lives

Issue a DELETE for the row and it becomes unreachable through the database. That is not the same as the bytes being gone. Depending on how the system is built, the data may persist in:

  • Backups. Last night's dump contains everything that existed last night. If you keep thirty days of backups, you keep thirty days of deleted secrets.
  • Replicas. The delete replicates, but a lagging or paused replica holds the old state until it catches up.
  • The write-ahead log. InnoDB records changes before applying them, and those logs are retained for a period.
  • Free pages. A deleted row marks space reusable; it does not overwrite it. The old bytes sit there until something else needs the page.
  • The storage layer. On an SSD, wear levelling means overwriting a logical block does not necessarily overwrite the physical one.

None of this is a flaw. Every one of those mechanisms exists for a good reason, mostly durability - the database is working hard to not lose your data, which is normally what you want.

The uncomfortable conclusion

A service running on ordinary infrastructure cannot honestly promise that deleted bytes are physically unrecoverable. Anyone claiming otherwise is either running unusual hardware or has not thought about it.

So we do the parts that are real, and then we make the question stop mattering.

What we actually do

  • Delete on the last view, inside the transaction. Not a flag, not a status column - the row is removed. A soft delete on this table would contradict the entire product.
  • Filter every query on expiry. An expired secret is unreachable the moment it expires, whether or not the sweep has run yet.
  • Sweep on a schedule. A cron job removes expired rows in batches, so they do not linger on disk waiting for someone to ask.
  • Exclude the table from backups. This one matters most and is the easiest to get wrong. A nightly dump would quietly preserve secrets that burned at ten in the morning, for as long as you retain backups. The table holds nothing worth recovering - every row is transient by design - so it is excluded outright.

The part that makes it true

Here is the argument that actually carries the weight, and it is not about deletion at all.

The decryption key was never on our side. It is generated in your browser and lives in the URL fragment, which browsers do not transmit. So a row recovered from a backup, a replica, a free page or a forensic image of the disk is ciphertext with no key anywhere near it.

This is sometimes called crypto-shredding: rather than proving the data is gone, you ensure the only thing that could ever have made it readable is gone - and in our case it was never present to begin with. The link is destroyed by the person who holds it, and with it the only copy of the key.

Deletion of the row is still worth doing, and we do it properly. But it is the belt. The key never existing on our side is the braces, and it is the reason the claim holds even in the cases where deletion alone would not.

All posts Share a secret