Skip to main content

How to Fix Twilio Signature Validation Failures in AWS Lambda

How to Fix Twilio Signature Validation Failures in AWS Lambda

Have you ever tested Twilio webhook validation in AWS Lambda, only to see validator.validate() fail over and over again - even though your code looks perfect? You parse the form data, flatten the parameters, and everything seems correct, yet Twilio insists that X-Twilio-Signature doesn't match.

If that sounds familiar, you might be missing one tiny argument in your parse logic:

lambda.py
params = urllib.parse.parse_qs(
raw_body,
keep_blank_values=True # ⚠️ crucial ⚠️
)

In this post, you'll learn why keep_blank_values=True can make or break Twilio's HMAC signature validation when building serverless apps with AWS SAM and AWS Lambda.

This single detail often goes unnoticed, but it can be the entire reason Twilio's signature fails. Let's dive in!

Why Twilio validates requests

When Twilio sends webhooks to your application (e.g., for inbound calls, SMS messages, etc.), it includes an X-Twilio-Signature header to prove the request is truly from Twilio. You can verify the authenticity by:

  1. Constructing the exact URL Twilio called (including path and optional trailing slash)
  2. Parsing all POST fields
  3. Sorting those fields by key
  4. Combining them into a string and hashing with your Twilio Auth Token via HMAC-SHA1

The twilio.request_validator.RequestValidator does this automatically under the hood.

If anything is off - like missing parameters, double-encoded strings, or empty fields - the signature won't match, and you'll see False instead of True.

The mysterious signature failures

Picture this: You've set your Twilio webhook to https://YOUR-API/Prod/endpoint/.

In your AWS Lambda code, you parse the form data via:

lambda.py
params = urllib.parse.parse_qs(raw_body)

Then you flatten it:

lambda.py
flattened_params = {k: v[0] for k, v in params.items()}

Finally, you do:

lambda.py
is_valid = validator.validate(full_url, flattened_params, twilio_signature)

But Twilio responds with False.

You confirm you have ✅ correct trailing slash, the correct ✅ Auth Token, and you're sure the ✅ phone numbers match.

What gives?

The Crucial Fix: keep_blank_values=True

One small tweak often solves it:

lambda.py
params = urllib.parse.parse_qs(
raw_body,
keep_blank_values=True
)

By default, Python's parse_qs() will ignore keys that have empty values - meaning if Twilio sends Param=&AnotherParam=Hello, the first param (which is empty) might get lost or stripped.

Twilio includes that empty parameter

Twilio, however, still includes that empty parameter in the HMAC signature. The mismatch kills your validation.

Looking at the raw_body = event.get("body", "") I noticed that Twilio sent several keys with empty values:

...&CalledZip=&ApiVersion=2010-04-01&CalledCity=&CallStatus=ringing...

If we parse it, we'll notice 4 empty keys ToZip,CalledZip,CalledCity and ToCity:

parsed_params = {
'CallerCountry': ['US'],
'Direction': ['inbound'],
'CallerState': ['CA'],
'ToZip': [''], # empty key
'CalledZip': [''], # empty key
'CalledCity': [''], # empty key
'ToCity': [''], # empty key
...
}

So when I went ahead and parsed the raw_body without keep_blank_values=True, those empty keys were ignored, resulting in parsed_params missing those values:

parsed_params = {
'CallerCountry': ['US'],
'Direction': ['inbound'],
'CallerState': ['CA'],
...
}

Conclusion

  • Raw body: CalledZip=&ApiVersion=2010-04-01...
  • Without keep_blank_values=True, the empty CalledZip might vanish from your final dictionary
  • Twilio's signature logic includes CalledZip=""
  • Your logic omits CalledZip entirely, generating a different string for HMAC
  • Result: validation fails

The final working code

Below is a minimal AWS Lambda function using AWS SAM that fixes the signature by including empty parameters:

lambda.py
import os
import json
import urllib.parse
from twilio.request_validator import RequestValidator

def lambda_handler(event, context):
# 1. Load Twilio Auth Token
auth_token = os.environ["TWILIO_AUTH_TOKEN"]
validator = RequestValidator(auth_token)

# 2. Construct the exact URL Twilio called
host = event["headers"].get("Host", "")
path = event["requestContext"]["path"] # Your endpoint path
full_url = f"https://{host}{path}" # If Twilio has a trailing slash, keep it

# 3. Extract Twilio signature
twilio_signature = event["headers"].get("X-Twilio-Signature", "")

# 4. Parse body with keep_blank_values=True
raw_body = event.get("body", "")
params = urllib.parse.parse_qs(
raw_body,
keep_blank_values=True # ⚠️ crucial ⚠️
)

# 5. Flatten multi-value fields
flattened_params = {k: v[0] if len(v) == 1 else v for k, v in params.items()}

# 6. Validate
is_valid = validator.validate(full_url, flattened_params, twilio_signature)

print("URL:", full_url)
print("Twilio Signature:", twilio_signature)
print("Flattened Params:", json.dumps(flattened_params, indent=2))
print("Is Valid:", is_valid)

if not is_valid:
return {
"statusCode": 403,
"body": "Signature validation failed"
}

return {
"statusCode": 200,
"headers": {"Content-Type": "application/xml"},
"body": "<Response><Say>All Good!</Say></Response>"
}

That's it.

Just one small parameter: keep_blank_values=True.

Now, your dictionary includes placeholders for those empty fields, matching exactly what Twilio expects.

Why this works

1. Twilio doesn't ignore blank fields

  • In Twilio's HMAC calculation, a param like CalledZip= still contributes CalledZip with an empty string to the final hash string.

2. Python's parse_qs() defaults

  • Without keep_blank_values=True, parse_qs() discards such parameters or sets them to [], messing up your final param dictionary.

3. Signature mismatch

  • Once your dictionary is missing an empty param that Twilio included, the concatenated HMAC string is different. Twilio's signature doesn't match, so False is returned.

By explicitly telling Python to keep blank values, you preserve the parameter as {"CalledZip": [""]} instead of dropping or ignoring it.

When flattened and sorted, it lines up perfectly with Twilio's internal logic, giving you a True result.

Wrapping up

When everything looks correct but Twilio validation still fails, remember:

keep_blank_values=True can make or break your signature check

It's a tiny detail that's easy to miss, especially if you're new to Twilio or Python's parse_qs().

With this fix, you'll ensure you're capturing all parameters - blank or otherwise - and matching Twilio's exact HMAC calculation.

Now your AWS SAM-deployed Lambda should confidently validate Twilio requests.

That's it! If you've spent hours seeing False from Twilio's RequestValidator (like I did), hopefully this quick fix spares you any more headaches.

Happy coding!