Verifying Stripe webhook signatures in Laravel without Cashier

Verify Stripe webhook signatures in a Laravel controller without Cashier. Raw payload, Stripe-Signature header, and constructEvent() — secure in minutes.

Steven Richardson
Steven Richardson
· 5 min read

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.

Once your webhook endpoint is secured, the next step is to dispatch the payload to a queued job for async processing rather than handling it inline — keeping your 200 response fast while doing the work in the background. Scaling Laravel queues in production covers the full queue architecture, including the idempotency patterns essential for reliable webhook processing.

Depending on which Stripe products you're integrating, you may also want to look at:

FAQ#

Can I verify the signature in middleware instead of the controller?

Yes, but it's cleaner in the controller. If you do it in middleware, make sure the middleware has access to the raw request body and runs before any body parsing. Most Laravel apps handle webhook verification in the controller for clarity.

What if my local Stripe CLI secret changes between restarts?

It will. The stripe listen output prints a new ephemeral secret each time it starts. Use that secret in your local .env. It's different from your production secret and expires when the CLI stops.

How do I prevent replay attacks on top of signature verification?

constructEvent() already rejects events older than 300 seconds. You can tighten this with a second parameter: constructEvent($payload, $sig, $secret, $tolerance=60) to reject anything over 60 seconds old. For extra safety, store processed event IDs in the database and skip duplicates.

Can I use the same webhook endpoint for test and live events?

Yes, the signature verification works for both. But you should set up separate webhook endpoints in your Stripe Dashboard — one for test mode (with your test secret) and one for live (with your live secret). This prevents mixing test and live data.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.