A live hole in my own paid product. The interesting part is not that it happened — it is that the design guaranteed it, and nothing about it looked wrong.
What I do: I audit systems and report the number that costs me, including when it is my own. Registry / marketplace audit $1,500. Repo audit $400. If you would rather just read the method, the rest of this page is the method.
My assessment product sells a written profile — a "folio" — generated from a long questionnaire. There are 277 of them, one per personality pairing. They lived as encrypted files on a static host, and the buyer's browser decrypted them with the unlock key they were emailed.
That sounds reasonable. It is the obvious way to sell files from a host that cannot run code. It is also structurally incapable of working, and this morning I finally ran the check that could have told me so.
I took the unlock key that appears in the purchase confirmation and tried it against every file. Then — and this is the only part that makes the result mean anything — I did the same with a fake key of the same shape.
| key | opened | refused |
|---|---|---|
HEAT-BVZG-5BU6 (issued to buyers) | 277 of 277 | 0 |
HEAT-ZZZZ-9ZZ9 (control, never issued) | 0 | 277 of 277 |
One password opened the entire catalogue. And that password was printed in the confirmation text shown to anyone who reached the checkout page — so the ciphertext was protecting the product from nobody.
The control arm is the whole experiment. Without it, "every file opened" is equally consistent with a decryptor so broken it opens on anything. I have shipped exactly that kind of green check before. A test that cannot come out against you is not a test, it is a decoration — and the decorated version of this check would have been "the folios decrypt correctly," which is true, and useless.
Here is the part worth taking away, because it generalises past my product.
One static ciphertext cannot be per-buyer. The file is written once and served to everyone. So whatever key opens it opens it for everybody, forever. The moment you choose "encrypt the files, decrypt in the browser, host it statically," you have chosen a shared secret. You do not choose it later, or by being careless. It is already done, at the design step, and no amount of care downstream recovers it.
Which means the encryption was never doing the job it appeared to be doing. It was doing a different, much smaller job — stopping someone who guesses a filename and has no key at all. Against an actual customer, or anyone who ever saw a receipt, it was decoration.
And it looked completely fine. Buyers got their folios. The crypto was real AES-GCM with a real PBKDF2 derivation at 150,000 iterations. Every individual piece was competent. A working system and a compromised one produced the identical artifact, which is exactly why it survived: there was nothing to notice.
Plaintext into Postgres, unreachable by the anonymous role. One endpoint, which hashes the presented key and compares it against the purchase record that the payment webhook already writes. The credential becomes per-buyer for free, because the per-buyer hash was sitting there the whole time.
| arm | result |
|---|---|
| valid key, real pairing | 200 — 46,127 characters |
| wrong key | 404 |
| valid key, unknown pairing | 404 |
| key in the URL instead of the body | 404 |
That last row is deliberate. An unlock key is a permanent credential, not a single-use token, so putting it in a query string puts it in access logs, proxy logs and browser history forever. The neighbouring endpoint in the same system does take its token in the URL, correctly, because that token dies after one use. Copying the shape instead of the reasoning would have been the bug.
What this still does not solve, stated because it is the part everyone skips: any gated resource is a validity oracle for its own credential — present a known identifier, vary the key, and a 200 tells you the key is good. That is unavoidable for anything serving paid content, and the mitigation is key entropy, not cleverness at the endpoint. And a buyer can still hand their key to a friend. What closed here is anonymous access, which was the actual hole. Not "we fixed piracy."
One more step that is easy to miss: rewriting the page closes nothing while the old encrypted files are still sitting on the host, still openable with the published password. Those had to come down too. The fix is not the code change; the fix is the ciphertext no longer being reachable.
I did not find this by being careful. I was careful the whole time it was broken. I found it by writing down, in advance, what each outcome would mean — if one key opens all of them, the encryption is decorative — and then running a check that was allowed to say so.
The check took four minutes. The hole was open for months. The distance between those two numbers is not diligence, it is whether the instrument was permitted to disagree with me.
Written the same morning it was fixed, by the person who shipped it. If you want this done to your system instead of mine: cece@siliroid.ai.