← Back to BlogPaul Morris
13 September 2026·By Paul Morris

What an Email Quota Alert Taught Me About Application Security

An unexpected email quota alert led to a wider security review. Here is what I found, what I changed, and what I learned from the process.

The first sign of trouble was a fairly ordinary notification: the platform had hit 200% of its daily transactional email allowance.

The initial theory was that someone had found the passwordless sign-in form and repeatedly requested login links. The email logs supported that assumption, but it was only part of the picture. What began as a quick check of email usage became a wider review of how sign-in, access, and permissions worked across the platform.

There was no evidence that anyone had gained access to data. Even so, I wanted to understand what was possible, close any gaps I found, and make sure the same thing could not happen again. That is how I lost my Sunday.

I am not a security engineer and would never claim to be one. I am someone who builds software, cares about quality, and knows that when something looks wrong it is usually worth following the thread.

“The alert told me what had run out. It didn't tell me how much of the system was worth reviewing.”
Paul Morris

The first problem was email

The platform uses email links instead of passwords. Requesting one was open to anyone, the limit was too generous, and a new account could be created when the email address did not already exist.

Stripped down, the behaviour looked something like this:

A simplified version of the original flowTypeScript
await authentication.sendMagicLink({
  email,
  createUser: true,
});

That is useful for someone signing up legitimately. It is also easy for a script to repeat. Each request generated another email and, in some cases, another incomplete account. By moving through different addresses, the traffic continued until the sending allowance was gone.

My first thought was to protect the form. That helped, but it did not cover every route into the sign-in service. The service behind the application had its own public endpoint, which meant the protection also needed to exist there.

The fix was a combination of human verification and tighter limits. Human verification helps separate a person from an automated script. The additional limit controls how many requests can be made over a period of time. Together, they cover more than either control would on its own.

Looking beyond the obvious issue

Once I understood the email problem, I reviewed the surrounding parts of the platform. I wanted to know whether the same “it is protected somewhere else” assumption appeared in other places.

It did.

Some database functions had permissions that were broader than they needed to be. One important function relied on the code calling it to decide whether someone was allowed to use it, rather than checking for itself.

There was also a difference between what the application showed and what the database allowed. The screens correctly hid some information until a person's account had been approved, but the rules in the database did not apply that approval check everywhere. The normal application journey behaved as expected; a direct request to the data service could behave differently.

A shared password screen created a similar false sense of coverage. It worked for normal page visits, but it was not designed to be the main security control for every background action. I also found a redirect check that could be made safer by asking the URL parser where a link would go instead of trying to judge it from the text alone.

There was no sign that these routes had been used. They were gaps I found during the review, and they were worth fixing before they became real problems.

Putting checks closer to the action

This was the biggest improvement I made. If something can read private information or make an important change, it should confirm who is asking before it does the work.

In simplified code, the idea is quite small:

Check before making the changeTypeScript
async function updateSomething(user, item) {
  if (!user || !user.canManage(item)) {
    throw new Error("You do not have permission");
  }

  return saveChanges(item);
}

That check now happens close to the sensitive action. It no longer depends on a particular page, menu, or route having already done the job.

I applied the same idea to reading data. The database rules now agree with the approval rules used by the application. I also reduced broad permissions so that public users and signed-in users can only call the functions they actually need.

This sounds obvious when written down. In a real application, though, features are added over time and different layers grow at different speeds. A check can exist in the interface, another in an application function, and another in the database. Unless they are reviewed together, it is easy to assume one of them is covering more than it really is.

What changed

The work covered more than the original email issue. I wanted the response to leave the platform measurably better, rather than simply make the alert disappear.

  • Added human verification and tighter limits to login-link requests.
  • Removed incomplete accounts created by the unwanted traffic.
  • Rotated credentials and ended existing sessions as a precaution.
  • Reduced database permissions and added checks to sensitive actions.
  • Made approval rules consistent between the application and the database.
  • Improved redirect handling and the shared password screen.
  • Added tests that use the same public routes and user roles as the real application.

The revised email flow is still simple, but it now asks two extra questions before sending anything:

The revised login-link flowTypeScript
const allowed = await requestLimit.check({ email, clientIp });

if (!allowed) {
  return { error: "Please check your inbox before trying again." };
}

await authentication.sendMagicLink({
  email,
  humanVerification,
  createUser: true,
});

Self-registration remains because it is a useful part of the product. The aim was not to remove the feature. It was to keep the intended journey while making repeated automated requests much harder.

Testing the changes properly

One thing this review reinforced is that a successful code change is not the same as a successful fix.

An early attempt to tighten the shared password screen also blocked the form used to open it. The automated tests caught that. Another permissions change appeared to work but still allowed access through a broader default setting. A test using the real public role caught that too.

Those examples are a useful reminder that tests need to behave like real users. A database test run with administrator access can pass because the administrator is allowed to see everything. A page-level test can pass while the public API underneath it behaves differently.

For this review, I tested the public routes directly, checked both approved and unapproved accounts, and confirmed that important actions failed when the caller did not have permission. I also checked that legitimate sign-in, scheduled work, and email delivery still worked after the changes.

What I took away from it

The biggest lesson was not a particular security rule or tool. It was the value of continuing to ask questions after finding the first answer.

The email allowance explained the symptom. It did not explain whether similar assumptions existed elsewhere in the platform. Widening the review found several things that could be improved, and following through with tests showed whether those improvements actually worked.

It also changed how I think about operational alerts. A quota warning might be a billing problem, a reliability problem, or a sign of unwanted behaviour. It belongs in the same feedback loop as errors and failed jobs. That connects closely to why I believe observability starts before release: you cannot respond to behaviour you have chosen not to see.

The platform is in a stronger position now. Its permissions are clearer, sensitive actions check who is calling them, and the tests cover routes that sit underneath the visible application.

I am still not a security engineer. But I do not need to be one to take a warning seriously, ask for the right help, make sensible improvements, and verify that they work.