Sultan Kautsar

An Automatic Kill Switch for Amazon SES

After a leaked key sent tens of thousands of phishing emails through Amazon SES, I built a kill switch that stops sending on its own and names who was sending. A month later, when hacked WordPress sites sent spam, it turned sending off on its own, and I replaced every site's SES key.

Cloud securityWeb hosting

5 min read

The Client

A company that sends email through Amazon SES in two AWS regions: its WordPress sites, about 50 across two servers, send form and notification emails, and several internal apps send on a schedule. In the quieter region, normal volume is around 60 emails a day.

The Problem

In September 2026 an attacker got hold of one of the company’s SES keys and used its domains to send phishing. More than 38,000 messages went out before the account reached its sending limit, and AWS paused sending in that region.

SES publishes quotas and reputation metrics, but nothing in it stops sending on its own. A second pause would stop the email of every site and app in the region, including the ones that had nothing to do with the problem. And after the first incident, the hardest question was the one nobody could answer quickly: which key, from which IP, with which sender address?

What I Did

  1. CloudWatch alarms Sends per minute, bounce rate, complaint rate
  2. SNS topic Every alarm goes to one responder
  3. Lambda responder Disables SES sending in that region
  4. Logs Insights query Top IAM users, IPs, and sender addresses
  5. Alerts
    • Telegram and PagerDuty Suspects plus the commands to act
    • Watchdog Fires on its own if the responder fails

SES Guard, one stack per region
SES Guard is one CloudFormation template, deployed in both regions because SES sending, metrics, and quotas are regional. Plain CloudWatch alarms watch sends per minute, with a limit set per region from its normal volume, and the bounce and complaint rates. Any alarm invokes a small Python Lambda that turns off account-level sending in that region.

Alerts that name the suspects
The responder queries the last 30 minutes of send events for the top IAM users, source IPs, and sender addresses, and sends them to Telegram, PagerDuty, and email. The Telegram message includes the commands to disable a suspicious key and to resume sending. Every SES event is kept in CloudWatch Logs for a year, and the log group survives even if the stack is deleted.

A guard that watches itself
If the stop call fails, the Lambda raises an error on purpose, which fires a separate watchdog alarm and makes SNS retry. The watchdog goes to email and PagerDuty directly, so it still works when the Lambda is what broke. A control script checks that everything is armed, sends test alerts, runs a real stop test, and resumes sending.

Keys tied to sites and servers
Each WordPress site has its own IAM user for SES, and a policy denies sending unless the request comes from one of the two web servers. A stolen key is useless from anywhere else, and one site can be cut off without touching the rest. While testing the guard I also found root account access keys on my own CLI and deleted them.

The real incident
In early October, sites compromised through a WordPress Core exploit sent about 8,000 emails in one hour from the quieter region, with 14% bounces and 1.2% complaints. The run averaged about 134 emails a minute, below that region’s limit of 250. A deny policy on every site’s IAM user stopped the sites during the cleanup, and SES Guard turned off sending in the region the next day, while the account went under review. The cleanup of the servers themselves is a separate case study: incident response for 50 WordPress sites.

Every key replaced, stored where a site can’t change it
A rotation script moved more than 30 sites on both servers from third-party SMTP plugins to a small must-use plugin of mine, created a fresh key for each, and kept every site’s sender address and name. The credentials live in a file outside the webroot that the site can read but not modify. The script works with the servers’ locked plugin folders, lifting the lock only around its own change, and moves the removed plugins to quarantine instead of deleting them. Once each new key was verified, it deleted the old one.

An answer for the review
The reply to the AWS review described the cause, the cleanup, the new keys, the IP restriction, and the guard. The emergency deny on the site users was lifted on October 19, after the cleanup.

The Stack

Layer Tool Role
Detection CloudWatch alarms Send spikes, bounce rate, and complaint rate
Response SNS, Lambda (Python) Stops SES sending and finds the top senders
Logging EventBridge, CloudWatch Every SES event kept for a year
Alerts Telegram, PagerDuty, email Suspects, commands, and a separate watchdog
Access IAM One user per site, sending only from the web servers
Rotation Python, wp-cli, mu-plugin New keys on every site, old keys deleted after a check
Deployment CloudFormation, Bash One template per region, a control and test script

The Result

The guard turned sending off on its own during a real incident, and every site now sends with a new key that only works from the web servers.

Before After
Spam ran until AWS paused the account SES Guard stops sending in the region on its own
No quick answer to “who was sending?” Top IAM users, IPs, and senders in every alert
SMTP keys stored by third-party plugins Per-site keys in a file the site can’t change
A stolen key worked from anywhere Keys work only from the two web servers
Old keys readable by the attacker Old keys deleted after the new ones were verified

The October run also showed the guard’s limit: a burst that stays under the per-minute limit is caught only by the bounce and complaint alarms, and those react after the spam has gone out. The full design is in my note on a circuit breaker for Amazon SES.

If your applications send through Amazon SES and nothing would stop a leaked key, get in touch.

Similar Problem?

New websites and landing pages, maintenance, migrations, and technical SEO for businesses and agencies.

More Work

Next step

Have a project in mind? Let's talk.

Tell me what you have and what you need. I usually reply within one working day with how I'd approach it.

Write to Me

Tell me about your project or idea. I usually reply within one working day.