All posts

Eighteen and a half million dollars, please reply urgently

· Henning Kjær

On 15 August a bounce notification landed in the Tolkra inbox. It was an advance-fee scam — the classic dying-benefactor letter, eighteen and a half million dollars, please reply urgently — addressed to forty-nine strangers. It had been sent from our own domain, signed with our own mail signature, through our mail provider.

Nobody had broken in. Somebody signed up for a free account and used a feature exactly as it had been built.

In short: a "Send test email" button in our flow editor let any signed-in user choose who the email went to and what the subject line said. That was enough to turn a preview feature into a mail relay. Pulling on the thread turned up four more places where the same underlying assumption had been made — including one that exposed billing and one that exposed other customers' documents.

The thing I actually want to talk about

I could write the whole post about the bug. But the part that has stayed with me for two weeks is not the bug. It is what the bounce notification did to my sense of what this job is.

I build Tolkra alone. The reason I build anything is that I like problems with solutions — a thing does not work, you understand why, you make it work, and now it works for everyone who uses it. That loop is honest and it is finite. And I am good at it.

Security is not that loop. Security is an open-ended argument with everyone on earth, and you have to win every round while they only have to win once. The moment you put something on the public internet you are not shipping to your users. You are shipping to your users and to an automated, tireless, globally distributed population of people probing every endpoint you own, for whom your product is not a product but a surface. They found the button faster than my first ten customers did.

Nobody warns you about the shift in identity. I set out to solve a problem for people who take pictures of invoices and manually key in data. I did not set out to run a security team. But there is no version of putting software into the world where you get to do the first thing and not the second, and if you are one person, the security team is you — at night, after the feature work, with no one to check your reasoning and no one to tell you which of the hundred things you have not thought about matters most.

That is genuinely stressful, and I think it is worth saying plainly rather than pretending it was all a calm learning experience. The bounce email did not just tell me I had a bug. It told me that my domain reputation, my customers' data, and my ability to send mail at all had been sitting on an assumption I had never consciously made. For a day I did not want to look at the codebase, because every part of it might contain the same assumption. Some of it did.

What got me out of that was reframing the goal. I cannot be unbreakable. What I can be is someone who reacts fast, fixes the whole class rather than the instance, and writes down what happened — which is what this post is.

What actually went wrong

The flow editor lets you set up an email destination for a flow's results and press "Send test" to see what it looks like. Under the hood, the button handed the mail service three things it had been given by the browser: who to send to, what the subject should say, and which flow it was for.

It checked that the person pressing the button was signed in. It did not check anything else.

Not whether the flow existed. Not whether the workspace existed. Not whether the person had any connection whatsoever to either. Signing up was free and took one email verification, and after that the feature would mail anything to anyone, as often as you liked, from a domain with a good reputation and a valid signature.

The scam text was in the subject line, padded with invisible characters so the readable part fell outside the preview pane. That detail is the giveaway: the body of the email was a template they could not change, so the subject was the only free-text field they had. They put the payload where the freedom was.

Why it was there

This is the part worth sitting with, because the mistake is not really in that one feature.

"The user must be signed in" reads like a security control. It is the strictest-sounding option that still lets your app work, and it is what you reach for when you want to lock something down. What it actually promises is much narrower:

A signed-in session proves that somebody is signed in. It says nothing about whether that person has any relationship to the thing they just named in their request.

Every part of the system that accepted an identifier — a workspace, a flow, a stored file — and then acted on it was trusting the caller to only name things that belonged to them. That is not a check. It is a convention, held up entirely by the fact that my own interface never sent anything else.

Once I started looking for the pattern instead of the bug, the same assumption turned up four more times: a billing endpoint that would open a payment portal for any workspace you named, a delivery endpoint that would re-fire another customer's webhooks, and an invitation endpoint that would mail an arbitrary link from a trusted domain. Underneath all of it, in the data layer, invitations to join a workspace were readable by anyone signed in — including the join link itself, which made taking over any workspace on the platform a two-step operation.

None of these had been exploited. All of them were reachable by anyone who had spent five minutes reading the network traffic of the app.

What changed

The test email now only ever goes to the one logged in, you. Not a validated recipient list — no list at all. The one you configure is ignored on that path, so there is nothing to outsmart. A preview is for the person setting up the output; there was never a reason for it to reach anyone else. I just hadn't said so.

Every operation that takes an identifier now establishes who is asking before it acts. Server-side, from the verified session, never from something the browser sent along. This is the fix that actually matters, and it is a rule about all future code as much as it is a change to existing code.

Data is now walled off per customer at the storage layer, not just in the interface. The database itself refuses reads and writes that cross a customer boundary, so a mistake in any one feature can no longer become a data leak.

Limits are enforced where they cannot be skipped. Usage caps used to be checked in the browser, which meant they were decorative — anyone talking to the API directly walked straight past them. Mail volume and run counts are now counted and capped on the server. Subject lines are stripped and truncated. Links in outgoing mail are checked against where they claim to go.

Three things I took away

Anything that sends mail on your behalf is an open relay until you have proven otherwise. The question is not "could this be abused?" — everything could. The question is "who is allowed to name the recipient, and what stops them naming someone else?" If the answer involves trusting the browser, there is no answer.

Free text goes wherever you leave room for it. I controlled the body, so they used the subject. Constraining one field just moves the payload to the next unconstrained one. The work is to enumerate every attacker-controlled piece of text that ends up in front of a human being, and bound all of them.

Being signed in is not the same as being allowed. The two are easy to conflate because the tooling makes the first one so easy to switch on. Every operation that accepts a name for something it will then act on has to work out the caller's actual relationship to that thing — every time, on the server.

And one for the solo founders

If you are building something on your own and this post has made you anxious, that is a reasonable response, and I do not want to talk you out of it entirely. The exposure is real. You will ship something with a hole in it, and somebody will find it, and it will probably not be a researcher writing you a polite email.

What I would offer is this. The instinct that got me here — treat everything as a problem with a solution — is not the wrong instinct, it just needs a wider frame. Security is not a feature you complete. It is a property you maintain, the same way you maintain uptime or performance, and like those it responds to habits far more than to heroics. Fix the class, not the instance. Write down what happened. Assume the next assumption you have not examined is also wrong, and go looking for it on a quiet afternoon rather than waiting for a bounce notification to find it for you.

And be honest in public about it. I published this because the version of this story where a solo founder quietly patches a hole and says nothing is the version that leaves the next person to learn it the same way I did.

A postscript, because the fix nearly caused its own outage

Walling off customer data needed a migration: set up the per-customer boundaries, then label the existing records so they land on the right side of them. I wrote it carefully so that it could run safely before the new rules went live, with nothing broken at any point.

Then I pushed to a branch that deploys automatically on push. The new rules went live immediately, with nothing yet labelled to satisfy them, and every existing user was locked out of their own data until the migration caught up.

"Remember to run this first" was never going to survive contact with an automatic pipeline. The shape that works is two deployments: add the new structure while the old rules still apply, migrate against a system that is fully working, then turn on the new rules. Nothing is ever broken, and nobody has to remember anything — which, when the entire team is one person at the end of a long day, is the only kind of safety that actually holds.

In total almost 2500 mails were sent, many bounced. No person reached out or contacted me. A lesson was learned and a site was updated.