Verifying Stripe webhook signatures in Laravel without Cashier
Every time I see a Stripe integration that skips webhook signature verification, I wince. It's a security hole — anyone can POST to your webhook endpoint with a fabricated payment_intent.succeeded event and trigger fulfilment logic without paying a thing. The fix is a handful of lines of PHP. Here's the complete setup, no Laravel Cashier required.
Why webhook signature verification matters
Stripe signs every webhook it sends with an HMAC-SHA256 signature derived from your endpoint's secret and the request timestamp. That signature arrives in the Stripe-Signature header:
t=1712345678,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a05bd539baad0e08d864b3,v0=...
Without verifying that header, your /stripe/webhook endpoint accepts anything from anywhere. A replay of a legitimate old event — or a completely fabricated payload — will sail straight through your handling logic. The constructEvent() method rejects events older than five minutes (300-second default tolerance), which also blocks replay attacks.
Skipping verification is a quick way to end up in a situation where someone triggers order fulfilment for free. Don't skip it.
Setting up the Stripe PHP SDK in Laravel
Install the SDK:
composer require stripe/stripe-php
Add your webhook signing secret to .env. This is the per-endpoint signing secret, not your API key — they're different values:
STRIPE_KEY=pk_live_...
STRIPE_SECRET=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
Register it in config/services.php:
'stripe' => [
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
The verification controller
Stripe sends raw JSON. The critical gotcha here: you must retrieve the raw request body before Laravel's request parsing touches it. Always use $request->getContent(), never $request->all() or $request->json(). If the body has been parsed and re-serialised — even with identical values — the signature check will fail because whitespace or key ordering may have changed.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function handle(Request $request): Response
{
$payload = $request->getContent(); // raw body — never use $request->all()
$sigHeader = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook_secret');
try {
$event = Webhook::constructEvent($payload, $sigHeader, $secret);
} catch (\UnexpectedValueException $e) {
// Payload was not valid JSON
return response('Invalid payload', 400);
} catch (SignatureVerificationException $e) {
// Signature didn't match — reject immediately
return response('Invalid signature', 400);
}
// Route to the right handler based on event type
match ($event->type) {
'payment_intent.succeeded' => $this->handlePaymentSucceeded($event->data->object),
'payment_intent.payment_failed' => $this->handlePaymentFailed($event->data->object),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event->data->object),
default => null, // Ignore events you haven't registered for
};
// Return 200 quickly — Stripe retries any response >= 400 or a timeout
return response('OK', 200);
}
private function handlePaymentSucceeded(object $paymentIntent): void
{
// Provision access, fulfil the order, send confirmation email, etc.
logger('Payment succeeded', ['id' => $paymentIntent->id]);
}
private function handlePaymentFailed(object $paymentIntent): void
{
logger('Payment failed', ['id' => $paymentIntent->id]);
}
private function handleSubscriptionDeleted(object $subscription): void
{
logger('Subscription cancelled', ['id' => $subscription->id]);
}
}
Register the route. Webhooks come from Stripe's servers, not a browser — there's no CSRF token. Exclude the path in bootstrap/app.php (Laravel 11+):
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'stripe/*',
]);
})
Then add the route:
// routes/web.php
Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle']);
Alternatively, put it in routes/api.php — that middleware group doesn't apply CSRF verification at all.
Testing with the Stripe CLI
Install the Stripe CLI, authenticate, then forward events to your local server:
stripe listen --forward-to localhost:8000/stripe/webhook
When it starts, the CLI prints a webhook signing secret. Use that as STRIPE_WEBHOOK_SECRET in your local .env — it's distinct from the one in your Stripe dashboard:
> Ready! You are using Stripe API Version [2026-03-04.preview].
> Your webhook signing secret is whsec_test_...
Trigger a specific event to exercise your handler:
stripe trigger payment_intent.succeeded
You'll see the event arrive in the CLI output and hit your controller immediately. The stripe listen output shows the HTTP response code, so you can confirm your 200 is returning correctly.
One production gotcha: Stripe doesn't guarantee event ordering
payment_intent.payment_failed can arrive before payment_intent.created if there's a delivery delay. Before acting on any event that changes order state or access, retrieve the current object from Stripe's API directly rather than trusting the event payload alone:
private function handlePaymentSucceeded(object $paymentIntent): void
{
$stripe = new \Stripe\StripeClient(config('services.stripe.secret'));
$fresh = $stripe->paymentIntents->retrieve($paymentIntent->id);
// Guard against stale or out-of-order events
if ($fresh->status !== 'succeeded') {
return;
}
// Safe to act — state confirmed against the API
logger('Payment confirmed succeeded', ['id' => $fresh->id]);
}
That's the full setup. The Stripe PHP SDK handles all the cryptographic verification — constructEvent() is doing the heavy lifting. You just need to feed it the raw body and the right header.
Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.