Handling Webhooks Securely in Laravel
Webhooks are crucial for modern applications. They allow 3rd-party services (Stripe, GitHub, Mailgun) to notify your app when events happen. However, they introduce significant security and performance challenges.
If you handle webhooks poorly, an attacker could fake events, or a flood of webhooks could bring down your server.
1. Verify the Signature
Using no verification is a security vulnerability. The sender usually signs the payload with a secret. You must verify it.
Example: Verifying GitHub Webhook
Laravel provides a validate method on the Request object via macros or manual checking, but checking signatures manually gives you full control.
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class VerifyGithubWebhook
{
public function handle(Request $request, Closure $next): Response
{
$signature = $request->header('X-Hub-Signature-256');
$payload = $request->getContent();
$secret = config('services.github.webhook_secret');
$computed = 'sha256=' . hash_hmac('sha256', $payload, $secret);
if (!hash_equals($computed, $signature)) {
abort(403, 'Invalid signature');
}
return $next($request);
}
}
Tip: Always use hash_equals to prevent timing attacks.
2. Process Asynchronously (Queues)
Never process heavy business logic inside the webhook controller. Webhooks expect a fast 200 OK response. If you take too long, the provider will timeout and retry, potentially causing duplicate processing.
Bad Pattern:
public function handle(Request $request) {
// Blocks for 5 seconds sending email
Mail::to($user)->send(...);
return response('OK');
}
Good Pattern:
public function handle(Request $request) {
// 1. Verify Signature (Middleware)
// 2. Dispatch Job
ProcessWebhookJob::dispatch($request->all());
// 3. Respond immediately
return response('Received', 200);
}
3. Idempotency (Prevent Duplicates)
Providers guarantee "at least once" delivery. This means you might receive the exact same webhook event twice.
Your processing logic must be idempotent.
If the event is invoice.paid, check if you've already processed this Invoice ID.
// Inside ProcessWebhookJob
public function handle()
{
$eventId = $this->payload['id'];
if (WebhookReceipt::where('event_id', $eventId)->exists()) {
return; // Already handled
}
// Process logic...
WebhookReceipt::create(['event_id' => $eventId]);
}
4. Ordering Issues
Webhooks don't always arrive in order. Use timestamps from the payload, not the time you received it.
If you receive subscription.updated (timestamp: 10:05) before subscription.created (timestamp: 10:00) due to network lag, your code should handle it gracefully or rely on the payload's state of truth.
5. Security - CSRF Exception
Remember to exclude your webhook route from CSRF protection in bootstrap/app.php (or VerifyCsrfToken middleware), because external services cannot provide a CSRF token.
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'webhooks/*',
]);
})
Summary
To sleep well at night:
- Verify Signatures: Ensure the request comes from the real provider.
- Queue Everything: Respond fast, process later.
- Handle Duplicates: Check event IDs.
- Monitor Failures: Use Laravel Horizon or Dead Letter Queues to spot failed webhook jobs.