12 min read

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.

AWS Security Cognito SMS DevOps

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.

TL;DR: SMS pumping attacks can cost thousands of dollars in minutes. Defense requires multiple layers: UI validation, rate limiting, Custom Lambda triggers, WAF rules, and AWS End User Messaging Protect configurations.

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:

  1. Finding vulnerable endpoints - Typically user registration or phone verification flows
  2. Automating requests - Using bots to rapidly request SMS codes
  3. Using premium numbers - Targeting high-cost destinations or premium-rate numbers
  4. 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.

Attack Pattern 1: High-Volume Premium Numbers
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:

Important: You may notice here that I'm excluding the NoUserAgent_HEADER and SizeRestrictions_BODY rules from the AWS Managed Rules Common Rule Set. This is intentional, as these rules can generate false positives in our specific use case.

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.

Want to implement these protections in your own application? Feel free to reach out if you have questions about any of the strategies discussed in this post. I'm happy to share more detailed implementation guidance and lessons learned from the trenches.