Laravel Webhook Signatures
#Laravel Webhook Signatures
Centralized, fail-closed webhook signature verification for the major email and service providers (Mailgun, SendGrid, Postmark, Resend/Svix, AWS SNS/SES and GitHub) in Laravel applications.
This package was born out of the need to eliminate the duplicated and buggy signature-verification logic scattered across several packages (laravel-help-desk, laravel-service-desk, laravel-mail, laravel-satis). Instead of every package re-implementing — and getting wrong — the same verification, they all depend on a single, audited and tested source of truth.
#Security principles
Every verifier follows the fail-closed principle:
- Missing or empty secret → verification fails (returns
false). There is never a fail-open path. - Secret material is always compared with
hash_equals(HMAC/basic-auth) oropenssl_verify(ECDSA/RSA) — constant time, no timing leaks. - Timestamp validation (replay protection) wherever the provider exposes a signed timestamp.
- Any missing or malformed header, field or certificate results in rejection.
#Compatibility
| Item | Supported versions |
|---|---|
| PHP | 8.2, 8.3, 8.4 |
| Laravel | 11.x, 12.x, 13.x |
| Orchestra Testbench | 9.x, 10.x, 11.x |
| Required extension | ext-openssl |
#Installation
composer require jeffersongoncalves/laravel-webhook-signatures
Publish the configuration file (optional):
php artisan vendor:publish --tag="webhook-signatures-config"
#Configuration
The meaning of the "secret" varies per provider. Define the values via .env:
WEBHOOK_MAILGUN_SIGNING_KEY=... # Mailgun signing key WEBHOOK_SENDGRID_VERIFICATION_KEY=... # SendGrid ECDSA verification key WEBHOOK_POSTMARK_BASIC_AUTH=user:password # Postmark Basic Auth credentials WEBHOOK_RESEND_SECRET=whsec_... # Resend Svix secret WEBHOOK_SNS_TOPIC_ARN=arn:aws:sns:... # expected TopicArn (SES via SNS) GITHUB_WEBHOOK_SECRET=... # GitHub webhook secret (HMAC-SHA256)
| Provider | Scheme | Secret meaning |
|---|---|---|
mailgun |
HMAC-SHA256 over timestamp + token |
webhook signing key |
sendgrid |
ECDSA (P-256/SHA-256) over timestamp + body, Twilio headers |
ECDSA verification key (PEM or base64 DER) |
postmark |
Basic Auth (hash_equals) |
credentials in user:password format |
resend |
HMAC-SHA256 base64 over id.timestamp.payload, svix-* headers |
Svix secret (with or without whsec_ prefix) |
sns |
X.509 certificate + openssl_verify over canonical string |
expected TopicArn (message pinned to the topic) |
github |
HMAC-SHA256 over raw body, X-Hub-Signature-256 header (sha256=<hex>); legacy X-Hub-Signature (sha1) fallback |
GitHub webhook secret |
The timestamp tolerance (in seconds) is configurable:
// config/webhook-signatures.php 'tolerance' => [ 'default' => 300, // Mailgun, Resend, SendGrid 'sns' => 3600, // SNS may redeliver messages later ],
#Usage
#1. Middleware (recommended)
The package registers the webhook.signature middleware alias, parameterized by provider. It aborts with 403 when the signature cannot be verified:
use Illuminate\Support\Facades\Route; Route::post('/webhooks/mailgun', InboundController::class) ->middleware('webhook.signature:mailgun'); Route::post('/webhooks/resend', ResendController::class) ->middleware('webhook.signature:resend');
The secret is read automatically from config('webhook-signatures.providers.{provider}.secret').
#2. Direct usage via Facade
use JeffersonGoncalves\WebhookSignatures\Facades\WebhookSignatures; public function handle(Request $request) { if (! WebhookSignatures::verify('sendgrid', $request)) { abort(403); } // ... process the event }
You can also pass the secret explicitly (bypassing the config):
WebhookSignatures::verify('mailgun', $request, $myKey);
#3. Using a standalone verifier
Each verifier implements the SignatureVerifier interface:
use JeffersonGoncalves\WebhookSignatures\Verifiers\ResendSignatureVerifier; $verifier = new ResendSignatureVerifier(tolerance: 300); $valid = $verifier->verify($request, $secret); // bool
#4. Registering a custom verifier
use JeffersonGoncalves\WebhookSignatures\Facades\WebhookSignatures; WebhookSignatures::extend('my-provider', MyVerifier::class);
MyVerifier must implement JeffersonGoncalves\WebhookSignatures\Contracts\SignatureVerifier and accept int $tolerance in the constructor.
#What each verifier does
- Mailgun — computes
hash_hmac('sha256', timestamp.token, $key)and compares it with the received signature viahash_equals. Accepts the fields at the top level (inbound routes) or nested undersignature(event webhooks). Rejects timestamps outside the tolerance window. - SendGrid — verifies the ECDSA signature (P-256/SHA-256) over
timestamp + raw body, reading theX-Twilio-Email-Event-Webhook-Signatureand-Timestampheaders. Normalizes the verification key (PEM or base64 DER) and usesopenssl_verify. - Postmark — Postmark does not sign the payload; authentication is via Basic Auth. Compares user and password in constant time (
hash_equals). - Resend (Svix) — reconstructs
id.timestamp.payload, computes HMAC-SHA256 with the decoded key (whsec_prefix stripped), base64-encodes it and compares against eachversion,signaturepair from thesvix-signatureheader. Rejects timestamps outside the tolerance. - GitHub — computes
hash_hmac('sha256', raw body, $secret)and compares it, viahash_equals, against theX-Hub-Signature-256header (sha256=<hex>format). As a fallback it accepts the legacyX-Hub-Signatureheader (sha1=<hex>), but always prioritizes SHA-256. A missing or malformed header results in rejection. - AWS SNS/SES — pins the message to the expected
TopicArn, validates that theSigningCertURLpoints to a legitimate AWS host (sns.<region>.amazonaws.com), reconstructs the canonical string documented by SNS, downloads the X.509 certificate and verifies the signature withopenssl_verify(SHA1 forSignatureVersion 1, SHA256 for2). Rejects messages that are too old.
#Testing
composer test # Pest composer analyse # PHPStan (level 5, Larastan) composer format # Laravel Pint
Each verifier has tests covering: valid signature accepted, invalid signature rejected, request without credentials rejected and (where applicable) old timestamp rejected. All cryptographic keys and fixtures are generated inside the tests.
#Migration (consumer packages)
This package consolidates signature verification that used to be duplicated (and divergent) across:
laravel-help-desk→src/Http/Middleware/Verify{Mailgun,SendGrid,Postmark,Resend}Signature.phplaravel-service-desk→src/Http/Middleware/Verify{Mailgun,SendGrid,Postmark,Resend}Signature.phplaravel-mail→src/Webhooks/{SendGrid,Ses,...}WebhookHandler::validate()laravel-satis→ its own GitHub webhook verification
Real problems found in the duplication:
- Fail-open:
help-deskreturned$next($request)when the key was not configured — i.e. it accepted any request. Here the behavior is always fail-closed. - Non-constant comparison:
service-desk(SendGrid/Postmark) used!==instead ofhash_equals, exposing it to timing attacks. - No replay protection: some Mailgun implementations did not validate timestamp recency.
Suggested migration steps (to apply per package):
- Add
jeffersongoncalves/laravel-webhook-signaturesto the packagecomposer.json. - Replace the package's own middlewares with the
webhook.signature:{provider}alias, or callWebhookSignatures::verify(...)inside the existing handler. - Map the current secrets (e.g.
help-desk.email.inbound.mailgun.signing_key) toconfig('webhook-signatures.providers.mailgun.secret')— or pass the secret explicitly as the third argument ofverify(), preserving the package config. - Remove the duplicated
Verify*Signature.phpfiles and their redundant tests. - Run the consumer package test suite.
#Changelog
Please see CHANGELOG for more information on what has changed recently.
#Contributing
Please see CONTRIBUTING for details.
#Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
#Credits
#License
MIT. See LICENSE.md.