Defending Against SMS Pumping Attacks in AWS Cognito
A deep dive into the evolution of SMS pumping attacks targeting free trial applications and the multi-layered defense strategies we implemented using AWS Cognito, Lambda triggers, WAF, and End User Messaging Protect.
The Problem: When Free Trials Become Expensive
SMS pumping attacks have become one of the most costly and sophisticated threats facing modern web applications, particularly those offering free trials with SMS verification. What starts as an innocent signup process can quickly escalate into thousands of dollars in SMS charges, making your "free" trial anything but free.
In this post, I'll walk you through our journey defending against these attacks, from our initial naive implementation to a robust, multi-layered defense system. We'll explore the different attack vectors we encountered, the evolution of our defensive strategies, and the lessons learned along the way.
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.
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 phone verification for several reasons:
- Reducing fake account creation
- Enabling SMS notifications for critical alerts
- Improving account recovery options
- Meeting compliance requirements for certain markets
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.
Phase 3: Adding Phone Verification (The basic implementation)
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 a few thousand in SMS charges from what appeared to be legitimate verification requests.
Attackers targeted premium-rate numbers in expensive regions (Africa) and used automated tools to request hundreds of verification codes per hour. We had sent $4,000 worth of SMS to Yemen and Tunisia!
The attack characteristics we observed:
- ~300 requests per minute to AWS Cognito endpoints
- Phone numbers from just Yemen and Tunisia
- Automated patterns (sequential timing)
- No subsequent account activity (never actually verified the codes)
Defense Evolution: Building Multiple Layers
Defense Layer 1: Cognito Country Code Validation
Our first line of defense 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
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
)
Defense 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 ot cognito and provided a good solution for implementing IP-based rate limiting:
Show WAF Configuration for SMS Protection
# WAF Configuration for SMS Protection
{
"Name": "cognito-waf",
"Id": "",
"ARN": "arn:aws:wafv2:::regional/webacl/cognito-waf/",
"DefaultAction": {
"Allow": {}
},
"Description": "WAF to protect against SMS pumping on Cognito ConfirmSignUp and ResendConfirmationCode actions",
"Rules": [
{
"Name": "RateLimitConfirmSignUp",
"Priority": 0,
"Statement": {
"RateBasedStatement": {
"Limit": 10,
"EvaluationWindowSec": 600,
"AggregateKeyType": "IP",
"ScopeDownStatement": {
"AndStatement": {
"Statements": [
{
"ByteMatchStatement": {
"SearchString": "confirmsignup",
"FieldToMatch": {
"Body": {}
},
"TextTransformations": [
{
"Priority": 0,
"Type": "LOWERCASE"
}
],
"PositionalConstraint": "CONTAINS"
}
},
{
"ByteMatchStatement": {
"SearchString": "application/x-amz-json",
"FieldToMatch": {
"SingleHeader": {
"Name": "content-type"
}
},
"TextTransformations": [
{
"Priority": 0,
"Type": "LOWERCASE"
}
],
"PositionalConstraint": "CONTAINS"
}
}
]
}
}
}
},
"Action": {
"Block": {}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "cognito-confirmsignup-ratelimited"
}
},
{
"Name": "RateLimitResendConfirmationCode",
"Priority": 1,
"Statement": {
"RateBasedStatement": {
"Limit": 10,
"EvaluationWindowSec": 600,
"AggregateKeyType": "IP",
"ScopeDownStatement": {
"AndStatement": {
"Statements": [
{
"ByteMatchStatement": {
"SearchString": "resendconfirmationcode",
"FieldToMatch": {
"Body": {}
},
"TextTransformations": [
{
"Priority": 0,
"Type": "LOWERCASE"
}
],
"PositionalConstraint": "CONTAINS"
}
},
{
"ByteMatchStatement": {
"SearchString": "application/x-amz-json",
"FieldToMatch": {
"SingleHeader": {
"Name": "content-type"
}
},
"TextTransformations": [
{
"Priority": 0,
"Type": "LOWERCASE"
}
],
"PositionalConstraint": "CONTAINS"
}
}
]
}
}
}
},
"Action": {
"Block": {}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "cognito-resendcode-ratelimited"
}
},
{
"Name": "AWS-AWSManagedIPReputationList",
"Priority": 2,
"Statement": {
"ManagedRuleGroupStatement": {
"VendorName": "AWS",
"Name": "AWSManagedRulesAmazonIpReputationList"
}
},
"OverrideAction": {
"None": {}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "shop-cognito-bad-ips"
}
},
{
"Name": "AWS-AWSManagedRulesCommonRuleSet",
"Priority": 3,
"Statement": {
"ManagedRuleGroupStatement": {
"VendorName": "AWS",
"Name": "AWSManagedRulesCommonRuleSet",
"ExcludedRules": [
{
"Name": "NoUserAgent_HEADER"
},
{
"Name": "SizeRestrictions_BODY"
}
]
}
},
"OverrideAction": {
"None": {}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "shop-cognito-owasp"
}
}
],
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "shop-cognito-waf-all"
},
"Capacity": 789,
"ManagedByFirewallManager": false,
"LabelNamespace": "awswaf:706289797190:webacl:shop-cognito-waf:",
"RetrofittedByFirewallManager": false,
"OnSourceDDoSProtectionConfig": {
"ALBLowReputationMode": "ACTIVE_UNDER_DDOS"
}
}
NOTE:
Attack Patterns We Encountereed
As our implementation began with clients updating their phone numbers from client to AWS Cognito API's, we didn't observe traffic directly. Instead, we relied on AWS CloudTrail logs and Amazon CloudWatch metrics to identify unusual patterns. We mostly got targeted by Pattern 2 & Pattern 3
Pattern 1: Temporary Emails With Same Alphanumeric Chars
The most common attack pattern we observed at the start was users wanting our product and scripting the creation of emails to automatically go through our free-trial process
- Volume: 30 requests per hour
- Targets: Free-Trial Product
- Timing: Spread out over time, usually outside business hours
Pattern 2: SMS Pumping Bursts
The first sms pumping pattern we observed was a high volume of requests on the last day of the month (because they're aware of monthly spending caps for end user messaging):
- Volume: 600 requests per hour
- Targets: Carefully selected high-value numbers
- Timing: Large burst of requests at the end of the month, usually outside business hours
- Countries: Limited to just 2 countries at a time
Pattern 3: SMS Pumping Slow Burn
More sophisticated attackers used a "slow burn" approach to avoid detection:
- Volume: 10-20 requests per hour, sustained over days
- Targets: Carefully selected high-value numbers
- Countries: Limited to just 2 countries at a time
Lessons Learned and Best Practices
1. Defense in Depth is Essential
No single layer of protection is sufficient. Our most effective defense combined:
- Client-side validation and rate limiting
- Server-side Lambda trigger interception
- Network-level WAF protection
- AWS service-level country restrictions
- AWS service-level monthly spending caps
- Monitoring and alerting systems
2. Monitor Everything
Comprehensive monitoring saved us thousands of dollars by catching attacks early. AWS Cost Anomaly Alerts first helped us identify attacks, then we refined our alerting to be based on Lambda Logs and then eventually End User Messaging metrics:
3. Regional Strategy Matters
Different regions have different cost structures and fraud patterns:
- High-risk regions: Block premium-rate prefixes entirely
- Medium-risk regions: Implement stricter rate limits
- Target markets: Allow higher limits with enhanced monitoring
4. User Experience vs Security Balance
Finding the right balance between security and user experience is crucial:
Security Measure | Security Impact | UX Impact | Recommendation |
---|---|---|---|
WAF Rate Limiting For Cognito | High | Low | ✅ Implement |
Cognito Lambda Trigger Validation | Medium | Medium | ✅ Implement |
End User Messaging Right Size Spending Cap | Medium | Low | ✅ Implement |
Email verification required | High | High | ✅ Implement |
Phone number verification required | High | High | ✅ Implement |
Implementation Checklist
Based on our experience, here's a practical checklist for implementing SMS pumping protection:
Phase 1: Immediate Actions
Phase 2: Server-Side Protection
Phase 3: Network-Level Protection
Phase 4: Advanced Protection
Cost Analysis: Before and After
The financial impact of our defense implementation was significant:
Before Protection
- Peak attack cost: ~$4,000
- Average monthly SMS fraud: $1,200
- Legitimate SMS costs: $600/month
- Total monthly SMS costs: $5,000
After Protection
- Blocked attack attempts: 99.7%
- Monthly fraud losses: $23
- Legitimate SMS costs: $600/month
- Protection infrastructure: $35/month
- Total monthly costs: $203
ROI: Our protection system saved us approximately $1,200 per month (85% cost reduction) while adding only $35 in infrastructure costs.
Conclusion: The Ongoing Battle
SMS pumping attacks continue to evolve, and our defense 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 defenses: 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 defenses
Looking Forward
As we continue to refine our defenses and the business grows, we're exploring:
- Machine learning models for anomaly detection
- Integration with third-party fraud detection services
- Advanced behavioral analysis of user signup patterns
- Alternative verification methods (app-based, email-first flows)
The battle against SMS pumping attacks is ongoing, but with the right strategy and tools, it's a battle that can be won. The key is to stay vigilant, keep learning, and always be ready to adapt your defenses to new threats.