Twilio Webhook Signatures Break Behind Load Balancers
Twilio's signature validation is more fragile than the docs let on. One body-parsing mismatch and you're silently dropping every inbound SMS.
Twilio's webhook signature validation is one of those things that works perfectly on your local machine and then silently fails in production for reasons that have nothing to do with your application code. I've been burned by this twice - once on a healthcare messaging integration and once setting up two-way SMS for a real estate client. Both times, inbound messages just disappeared without a trace.
The failure mode is the worst kind: no exception, no error log, just a 403 that Twilio logs on their end and your app never sees.
What Twilio Signature Validation Actually Does
When Twilio hits your webhook, it sends an X-Twilio-Signature header. That signature is an HMAC-SHA1 computed over:
- The full URL Twilio thinks it's posting to (scheme, host, path, query string - all of it)
- All POST parameters, sorted alphabetically by key, concatenated as key+value pairs
Your app recomputes that HMAC and compares it. If they match, the request is legitimate. The Twilio Helper Library wraps this for you.
In Laravel it looks like this:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Twilio\Security\RequestValidator;
class ValidateTwilioSignature
{
public function handle(Request $request, Closure $next)
{
$validator = new RequestValidator(config('services.twilio.auth_token'));
// The URL must match exactly what Twilio has on record
$url = $request->url();
$signature = $request->header('X-Twilio-Signature', '');
// POST params - not the raw body, the parsed form fields
$params = $request->post();
if (!$validator->validate($signature, $url, $params)) {
abort(403, 'Invalid Twilio signature');
}
return $next($request);
}
}
That code is correct. The problems live outside it.
The URL Problem
This is the one that kills you behind a load balancer or reverse proxy.
Twilio signs the request against https://yourdomain.com/webhooks/sms. Your load balancer terminates SSL and forwards the request to your app server over plain HTTP on port 8080. Inside your app, $request->url() returns http://your-internal-host:8080/webhooks/sms. The HMAC fails. Every time. Silently.
Laravel has TrustProxies middleware exactly for this. Make sure it's configured:
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
// Trust all proxies - lock this down to your LB IP range in production
protected $proxies = '*';
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO;
}
Your load balancer also needs to actually send those headers. Nginx example:
location /webhooks/sms {
proxy_pass http://app_upstream;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Host $host;
}
Without X-Forwarded-Proto, Laravel will not reconstruct the https scheme, and the URL you pass to the validator will be wrong.
The Body Parsing Problem
This one is subtler and I've seen it trip up developers who thought the proxy issue was the only thing to fix.
Twilio posts as application/x-www-form-urlencoded. The signature is computed over the decoded, sorted key-value pairs from that body. If anything in your stack is re-encoding, double-decoding, or reordering those parameters before you hand them to the validator, the HMAC will not match.
Common culprits:
Global JSON middleware. Some Laravel setups have middleware that tries to parse any incoming POST as JSON and re-set the request parameters. If that runs before your Twilio middleware, you can end up with mangled params.
Body buffering in the proxy. If your proxy is doing something weird like modifying or re-encoding the body - I've seen this with some WAF configurations - the raw body your app receives won't match what Twilio signed.
Stripping or normalizing query strings. If you pass query parameters in your webhook URL (e.g., /webhooks/sms?channel=support) and your proxy strips or reorders them, the URL string used in the HMAC won't match.
The diagnostic step is to log everything at the point of validation:
use Illuminate\Support\Facades\Log;
Log::debug('Twilio validation debug', [
'url' => $request->url(),
'signature' => $request->header('X-Twilio-Signature'),
'params' => $request->post(),
'content_type' => $request->header('Content-Type'),
]);
Then trigger a test message and compare against what Twilio's debugger shows it sent. The mismatch will be obvious once you can see both sides.
The Middleware Order Problem
Laravel's VerifyCsrfToken middleware will reject Twilio's webhook before your signature validator ever runs, because there's no CSRF token in the request. You need to exclude your webhook route.
In VerifyCsrfToken:
protected $except = [
'webhooks/sms',
'webhooks/sms/*',
];
Also: make sure your Twilio signature middleware runs early, before any middleware that might read and consume the request body or alter the parameters. In Laravel, middleware registered in $routeMiddleware and applied at the route level runs after the global stack, so the ordering is:
Global middleware (TrustProxies, etc.)
-> Route middleware (your ValidateTwilioSignature)
-> Controller
That ordering is fine. What's not fine is if you have a global middleware that munges POST data before the route middleware can validate it.
The Auth Token Problem
Twilio gives you a primary and secondary auth token. The helper library only checks one. If you've recently rotated tokens and your environment variable hasn't updated everywhere - particularly on app servers that don't get redeployed as part of your normal CI pipeline - some servers will validate correctly and others won't. This produces an intermittent failure that looks completely random under load balancing.
I've dealt with this. It is maddening. The fix is boring: make sure your auth token is consistent across every server in your pool, verify it after any rotation, and log which server handled a failed request.
A More Robust Validator
Here's the version I now use in projects that sit behind load balancers. It adds explicit logging on failure and constructs the URL from parts I can inspect:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Twilio\Security\RequestValidator;
class ValidateTwilioSignature
{
public function handle(Request $request, Closure $next)
{
$token = config('services.twilio.auth_token');
if (empty($token)) {
Log::error('Twilio auth token not configured');
abort(500);
}
$validator = new RequestValidator($token);
$signature = $request->header('X-Twilio-Signature', '');
$url = $request->url();
$params = $request->post();
if (!$validator->validate($signature, $url, $params)) {
Log::warning('Twilio signature validation failed', [
'url' => $url,
'signature' => $signature,
'param_keys' => array_keys($params),
'server' => gethostname(),
]);
abort(403);
}
return $next($request);
}
}
Logging gethostname() is the detail that saved me during the rotating-token incident. I could immediately see that failures were all coming from one specific app server.
When I'd Reach for Twilio Signature Validation
Always. There is no legitimate reason to skip it on a production system. SMS webhooks are unauthenticated HTTP endpoints. Without validation, anyone who knows or guesses your webhook URL can inject arbitrary messages into your application - fake patient replies in a healthcare workflow, fake order confirmations in e-commerce. The validation is dead simple when everything is configured correctly.
If you're early in development and just want the webhook to work, you can disable validation temporarily, but gate it with an environment check so it's impossible to leave off in production:
if (app()->environment('production', 'staging')) {
// validate
}
I wouldn't skip validation in staging either - that's exactly the environment where you'll catch proxy misconfiguration before it bites you in production.
When This Gets Complicated
If you're receiving MMS and doing anything with the media URLs Twilio sends, those parameters are included in the signature too. MediaUrl0, MediaContentType0, and so on. This isn't usually a problem, but if you have code that filters or transforms the POST params before passing them to the validator - maybe to strip out values you don't care about - you'll break the signature check. Pass all POST params unmodified.
Also: Twilio's RequestValidator does support validating JSON payloads for newer webhooks (Twilio Functions, some Event Streams), but standard SMS webhooks are form-encoded. Make sure you're not accidentally treating them as JSON.
Twilio's signature validation is fundamentally solid. The spec is simple, the library does what it says, and it's been stable for years. What catches people is the gap between what Twilio thinks the request looks like and what your app server actually sees after a proxy, a WAF, and a middleware stack have all had a go at it. Close that gap and you're fine.
Need help shipping something like this? Get in touch.