Defending Against SMS Pumping Attacks in AWS
A deep dive into the evolution of SMS pumping attacks targeting free trial applications and the multi-layered defence strategies we[...]

A deep dive into the evolution of SMS pumping attacks targeting free trial applications and the multi-layered defence strategies we implemented using AWS Cognito, Lambda triggers, WAF, and End User Messaging Protect.
Table of Contents
The Rationale: Why Offer Free Trials?
Getting people to your website can be very expensive, especially for growing businesses. There's many ways to do it and usually they eat into your profit margins.
One tactic, instead of/alongside paying 3rd parties (such as paid social & paid search) can be to offer a free trial of your product. For growing businesses, new customers can shy away from unknown brands so to overcome this, having a free-trial is a good way to promote familiarity which in turn, hopefully improves conversion rates.
This blog post discusses the implementation details and evolution of a free-trial that I was involved in. At it's core, the main goal of the free-trial is to improve conversion rates while also being diligent of the costs compared to alternative marketing methods.
The Problem: When Free Trials Become Expensive
When conversion rates are of high importance to free trials, it's important to deter misuse of the services by limiting the free trial to one per person. But how would you do that? There's no sure fire way of doing it in an online platform, but there are controls you can put in place such as email verification, captcha and sms verification. Now the cost of the free-trial isn't just the cost of the product you're giving away, it's now opened up to the costs associated with the protections.
These protections are often abused by bad actors online and without proper consideration, can soon turn into a nightmare
In this post, I'll walk you through our journey defending against these attacks, from our initial implementation to a robust, multi-layered defence system. We'll explore the different attack vectors we encountered, the evolution of our defensive strategies, and the lessons learned along the way.
“A complex system that works is invariably found to have evolved from a simple system that worked.” — John Gall, Systemantics (a.k.a. The Systems Bible).
Our Journey: From Email-Only to Multi-Layer Defense
Phase 1: The Innocent Beginning (Email-Only)
Initially, our free trial application was very simple. Users could sign up with just their email address, receive a verification link, and get their product immediately. No SMS, no phone numbers, no problems. We did this MVP style to get feedback fast
// Our original, simple approach
const signUp = async (email: string, password: string) => {
return amplify.auth.signUp({
username: email,
password,
attributes: { email }
});
};
This worked perfectly for our target audience and kept costs predictable. But as we grew, we realized we needed another form of verification. We analysed our free-trial usage and observed some patterns of misuse.
Phase 2: The first attack wave
With just email verification, we noticed hundreds of automated requests availing of our free trial with the same alphanumeric characters in the email, just with different '.'s moved around.
This highlighted the need for stronger verification methods to prevent abuse. On our backend, we added 3rd party mail verification service (https://myemailverifier.com/) and we added logic to strip non-alphanumeric characters in our email verification system. We moved away from amplify in our front end and changed the calls to be done by our backend so we would have more control.
Phase 3: Adding Phone Verification (The basic implementation)
Even with using 3rd party email verification + our own back end processing logic, we still observed misuse. Creating temporary emails is too easy these days and wasn't enough of a speed bump to deter misuse.
We implemented phone verification using AWS Cognito's built-in SMS capabilities, thinking the AWS infrastructure would handle security for us. Our implementation was straightforward. I would highly recommend only doing this alongside additional security measures
// Our basic implementation
const verifyPhoneNumber = () => {
return amplify.auth.sendUserAttributeVerificationCode({
userAttributeKey: 'phone_number'
});
};
const handleResendCode = async () => {
try {
await verifyPhoneNumber();
setMessage('Code sent successfully');
} catch (error) {
setError('Failed to send code');
}
};
This implementation had several critical vulnerabilities:
- No rate limiting on the client side
- No validation of phone number formats or regions
- No protection against automated requests
- Direct exposure of the SMS sending function to the frontend
Phase 4: The Second Attack Wave
Three weeks after launching phone verification, we woke up to an AWS Cost Anomaly alert that left a lasting impression. Overnight, we had accumulated over $7,000 in SMS charges from what appeared to be legitimate verification requests. It was extremely confusing, we checked the free-trial uptake and it was a fraction of the number SMS sent.

We couldn't understand why anyone would just use our site to send thousands of SMS, until we looked at the End User Messaging dashboard, we could see they were all sent to two countries:
- Yemen
- Tunisia

At the time, End User Messaging Protect Configurations were only for End User Messaging, you couldn't utilize them if you were using AWS Cognito (and SNS) as a proxy (Now you can though). We quickly had to disable our free trial to figure out what was happening. We were able to do this in AWS SNS by disabling SMS.
We did some research and discovered a term we hadn't heard of before, SMS Pumping.
What is SMS Pumping?
SMS pumping is a type of fraud where attackers exploit SMS verification systems to generate revenue through premium-rate phone numbers. The attack works by:
- Finding vulnerable endpoints - Typically user registration or phone verification flows
- Automating requests - Using bots to rapidly request SMS codes
- Using premium numbers - Targeting high-cost destinations or premium-rate numbers
- Revenue sharing - Earning money from the SMS charges through revenue-sharing agreements with carriers
The financial impact can be severe. Attack campaigns can generate $10,000+ in SMS charges in under an hour, making this a critical business risk for any application using SMS verification.
Defence Evolution: Building Multiple Layers
Defence Layer 1: Cognito Country Code Validation
Our first line of defence focused on the Cognito flow for phone number verification
// Lambda Cognito Country Code Checking
exports.handler = async (event) => {
switch (event.triggerSource) {
case 'CustomMessage_UpdateUserAttribute':
return await checkCountryCode(event);
}
return event;
};
async function checkCountryCode(event) {
const phoneNumber = event.request.userAttributes.phone_number;
// If phone number isn't present, we're not going to send SMS anyway
if (!phoneNumber) {
console.warn(`User '${event.userName}' has no 'phone_number' attribute set up. Skipping check...`);
return event;
} else {
const allowedCountryCodes = [
'+44', // United Kingdom
... // Add other allowed country codes here
];
const phoneNumber = event.request.userAttributes.phone_number || '';
const isAllowed = allowedCountryCodes.some(code => phoneNumber.startsWith(code));
if (!isAllowed) {
console.warn(`Blocked phone number (not in whitelist): ${phoneNumber}`);
throwError(errorCodes.countryCodeNotAllowed);
}
return event;
}
}
We created this Lambda function and then added it to our Cognito user pool as a trigger
#Cloudformation Snippet For Cognito Setup
Resources:
CognitoUserPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: MyUserPool
LambdaConfig:
CustomMessage: !\GetAtt CustomMessageLambda.Arn
CustomMessageLambda:
Type: AWS::Lambda::Function
Properties:
FunctionName: CustomMessageLambda
Handler: index.handler
Role: !\GetAtt LambdaExecutionRole.Arn
Code:
ZipFile: |
// Lambda function code goes here
Runtime: nodejs20.x
PermissionForCognitoToInvokeLambda:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !\Ref CustomMessageLambda
Action: "lambda:InvokeFunction"
Principal: "cognito-idp.amazonaws.com"
SourceArn: !\GetAtt CognitoUserPool.Arn
This helped reduce the attack surface, but after needing to upgrade Amplify a few months later, we discovered that the CustomMessage_UpdateUserAttribute was being bypassed and we received an alert we had configured when our SMS sending went above a normal threshold.
We did some analysis and the change to AmplifyV5 changed the flow from UpdateUserAttribute to ConfirmSignUp and ResendConfirmationCode 🤯
Defense Layer 2: AWS End User Messaging Protect Configurations
This time around, we saw that AWS had just announced that End User Messaging could now apply Protect Configurations at an account default level, this wasn't present when we first embarked on this journey. We quickly applied this to our account with our whitelisted countries and hay presto, we blocked the SMS pumping attack. Unfortunately, at the time of writing, there was no support for CloudFormation for this so we created the protection manually. You could also use this boto3 script below
# End User Messaging Protect Configuration
import boto3
client = boto3.client('pinpoint-sms-voice-v2') # AWS End User Messaging v2
# Step 1: Create configuration
create_resp = client.create_protect_configuration(
ClientToken="block-some-countries",
DeletionProtectionEnabled=False,
Tags=[{"Key": "Name", "Value": "DevOpsProtectConfig"}]
)
config_id = create_resp['ProtectConfigurationId']
# Step 2: Set country rules
client.update_protect_configuration_country_rule_set(
ProtectConfigurationId=config_id,
NumberCapability="SMS",
CountryRuleSetUpdates={ #Example list, modify as needed
"GB": {"ProtectStatus": "ALLOW"},
"TU": {"ProtectStatus": "BLOCK"},
"SE": {"ProtectStatus": "MONITOR"}
}
)
# Step 3: Use in sending a message
send_resp = client.send_text_message(
DestinationPhoneNumber="+1234567890",
OriginationIdentity="+1234567560",
MessageBody="Hello from DevOpsTopher!",
MessageType="TRANSACTIONAL",
ProtectConfigurationId=config_id
)
Defence Layer 3: WAF Rate Limiting
While the End User Messaging Protect configuration was effective, we needed additional protection at the network level. AWS WAF had recently been integrated to Cognito and provided a good solution for implementing IP-based rate limiting. The only issue we had with this, is it didn't block slow-burn attacks. Our latest attack, had been at a rate of 1 SMS Per 30 Seconds which is the exact threshold that WAF becomes unable to rate limit (It's 10 requests in a 5 minute period, ATOW)
So implementing WAF would block high bursts of requests, but it wouldn't help us with slow burns
Defence Layer 4: Back End Rate Limiting
Our most recent protection that we applied is a back end rate limiting system using an AWS ValKey cache + Java Spring Aspect on the API Endpoints that we use to update user attributes (which is what the free-trial uses)
With this last defence in place, we've been SMS Pumping free for a few weeks now. We will continue to monitor the situation and have implemented alerting in a few places to let us know if it happens again.
Conclusion: The Ongoing Battle
SMS pumping attacks continue to evolve, and our defence strategies must evolve with them. The multi-layered approach we've implemented has been highly effective, but it's not a "set and forget" solution.
Key Takeaways
- Start with protection: Don't wait for an attack to implement security measures
- Layer your defences: No single solution is sufficient
- Monitor continuously: Early detection saves money and reputation
- Balance security and UX:Overly restrictive measures can harm legitimate users
- Learn from attacks: Each attack provides valuable intelligence for improving defences