The Vulnerability of Incoming Webhooks
When integrating third-party services like Stripe, Twilio, or GitHub into your B2B SaaS at Smart Tech Devs, webhooks are essential. They allow your application to react instantly to external events—like a successful subscription payment. However, exposing a public endpoint to receive these webhooks introduces two massive architectural risks: Spoofing and Duplicate Delivery.
If you blindly trust incoming POST requests, malicious actors can send fake payloads to your webhook URL, granting themselves premium access for free. Furthermore, webhook providers guarantee "at-least-once" delivery. This means if a network hiccup occurs, Stripe might send the exact same "Payment Received" webhook three times. If you don't handle this correctly, you will credit the user's account three times for a single payment.
Defense Layer 1: Cryptographic Signature Verification
The first step is ensuring the payload actually came from the trusted provider. Providers send a cryptographic signature in the HTTP headers (usually an HMAC SHA256 hash). We must calculate this hash locally using our secret key and compare it to the incoming header.
We implement this via a Laravel Middleware to protect the route before it even hits the controller.
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class VerifyWebhookSignature
{
public function handle(Request $request, Closure $next)
{
// 1. Grab the signature from the headers
$signature = $request->header('X-Provider-Signature');
if (!$signature) {
throw new AccessDeniedHttpException('Missing signature.');
}
// 2. Calculate the expected hash using the raw payload and your secret
$payload = $request->getContent();
$secret = config('services.provider.webhook_secret');
$computedSignature = hash_hmac('sha256', $payload, $secret);
// 3. Use hash_equals to prevent timing attacks
if (!hash_equals($computedSignature, $signature)) {
throw new AccessDeniedHttpException('Invalid signature.');
}
return $next($request);
}
}
Defense Layer 2: Idempotency via Redis
Once we trust the sender, we must prevent duplicate processing. We achieve this by making our webhook endpoints Idempotent—meaning applying the same operation multiple times yields the same result as applying it once.
Every webhook payload includes a unique Event ID. We use Laravel's Cache (backed by Redis) to lock this ID. If we see the same ID again, we acknowledge receipt to the provider (HTTP 200) but skip our internal processing.
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class WebhookController extends Controller
{
public function handlePaymentEvent(Request $request)
{
$eventId = $request->input('event_id');
// Use a Redis atomic lock via Cache::add()
// It returns true if the key didn't exist (first time), false if it did.
// We set a TTL of 24 hours to keep Redis clean.
$isFirstTime = Cache::add("webhook_processed_{$eventId}", true, now()->addHours(24));
if (!$isFirstTime) {
// We already processed this exact webhook. Acknowledge and abort.
Log::info("Duplicate webhook skipped: {$eventId}");
return response()->json(['status' => 'ignored', 'reason' => 'duplicate']);
}
// Proceed with complex business logic (e.g., updating tenant billing status)
// ...
return response()->json(['status' => 'success']);
}
}
Conclusion
Durable SaaS platforms are built on defensive engineering. By architecting signature verification middleware and Redis-backed idempotency locks, you transform fragile, vulnerable API endpoints into enterprise-grade webhooks capable of safely processing millions of external events without data corruption.
Top comments (0)