cazicaa / hookguard
Verify incoming webhook signatures from any PSP in Laravel. One call, one result, no magic.
Requires
- php: ^8.2
- ext-json: *
- illuminate/contracts: ^11.0 || ^12.0
- illuminate/support: ^11.0 || ^12.0
Requires (Dev)
- laravel/pint: ^1.13
- orchestra/testbench: ^9.0 || ^10.0
- pestphp/pest: ^2.34 || ^3.0
- phpstan/phpstan: ^1.11 || ^2.0
README
Is this webhook's signature valid — and if so, here's the payload. That is the whole job.
Every PSP integration needs that question answered correctly, and every PSP answers it slightly
differently. Stripe hashes "{timestamp}.{body}" and sends t=…,v1=…. Others send a bare HMAC
digest. Hookguard gives each scheme its own small class, picks one per provider from config, and
hands you back a result you can match on.
You call it from your own controller. There is no middleware to install, no route macro, no event to listen for.
Works with any PSP
Every HMAC-based webhook scheme in the wild boils down to one of two shapes: a plain digest of the
raw body, or a digest of the raw body glued to a timestamp. Hookguard ships both, tested, out of the
box — hmac and signed_payload — so most providers need nothing but a config entry, not a line of
code.
For the rest, VerifierRegistry::extend() is a first-class, documented extension point — not a fork,
not a pull request, not a wait. A provider with a genuinely different scheme (field concatenation, a
non-standard encoding, a signature computed over something other than the body) is a small class in
your app, registered in one line. See Writing a custom verifier.
Verified against real providers
| Provider | Verifier | Signature header | Notes |
|---|---|---|---|
| Stripe | signed_payload |
Stripe-Signature |
t=…,v1=…, rotation-aware |
| HaruPay | hmac |
X-HaruPay-Signature |
hex digest of the raw body |
| PayTrust (Finovra) | hmac |
Signature |
hex digest of the raw body |
'harupay' => [ 'verifier' => 'hmac', 'secret' => env('HARUPAY_WEBHOOK_SECRET'), 'algorithm' => 'sha256', 'signature_header' => 'X-HaruPay-Signature', ], 'paytrust' => [ 'verifier' => 'hmac', 'secret' => env('PAYTRUST_SIGNING_KEY'), 'algorithm' => 'sha256', 'signature_header' => 'Signature', ],
This list grows as more integrations get exercised end to end. If you've verified Hookguard against a provider that isn't listed, open a PR — it's one row and it helps the next person integrating that PSP.
What it does not do
Deliberately, so you know what you still own:
- No replay protection and no timestamp tolerance. Hookguard uses the timestamp because it is part of what was hashed, but it never judges whether that timestamp is recent.
- No deduplication. If a provider delivers the same event twice, you get two verified results.
- No database, no cache, no state of any kind.
- No outgoing webhooks. This is receive-side only.
If you need replay or dedup protection, build it in your controller on the Verified branch — you
have the decoded payload and its event id right there.
Requirements
- PHP 8.2+
- Laravel 11 or 12
Installation
composer require cazicaa/hookguard
The service provider is auto-discovered. Publish the config when you're ready to add a provider:
php artisan vendor:publish --tag=hookguard-config
Quick start
Configure the provider in config/hookguard.php:
return [ 'providers' => [ 'stripe' => [ 'verifier' => 'signed_payload', 'secret' => env('STRIPE_WEBHOOK_SECRET'), 'algorithm' => 'sha256', 'signature_header' => 'Stripe-Signature', 'timestamp_header' => null, ], ], ];
Then call it:
use Hookguard\Hookguard; use Hookguard\HookguardStatus; use Illuminate\Http\Request; final class StripeWebhookController { public function __invoke(Request $request, Hookguard $hookguard) { $result = $hookguard->verify( $request->getContent(), $request->headers->all(), 'stripe', ); return match ($result->status) { HookguardStatus::Verified => $this->handle($result->payload()), HookguardStatus::HashMismatch => response()->json(['error' => 'invalid signature'], 401), HookguardStatus::UnsupportedProvider => response()->json(['error' => 'unknown provider'], 400), HookguardStatus::Error => response()->json(['error' => $result->message()], 500), }; } }
Exclude the route from CSRF protection, and pass $request->getContent() — never $request->all().
Why.
The result
verify() returns a HookguardResult. Its whole surface is $result->status, $result->payload()
and $result->message(), plus $result->provider for logging.
$result->status |
Means | message() |
|---|---|---|
HookguardStatus::Verified |
The signature checks out. payload() is safe to read. |
null |
HookguardStatus::HashMismatch |
The provider's math didn't check out — wrong secret, tampered body, missing or malformed signature header. | null |
HookguardStatus::UnsupportedProvider |
You asked for a provider name that isn't in your config. | names the provider |
HookguardStatus::Error |
Your own setup is broken — no secret, unknown verifier name, unsupported hash algorithm, or a verifier that threw. | describes the problem |
Those three failure causes are kept strictly apart. A misconfiguration will never reach you as a
HashMismatch, so an alert on "we're getting mismatches" always means what it says.
payload() is the JSON-decoded body. It throws a PayloadUnavailable exception on any result
that isn't Verified — reading unchecked webhook data is a bug, not a case to handle.
message() is written for a developer reading logs. It never contains a secret or a signature, but
it does describe your configuration, so think before echoing it back to whoever called your endpoint.
Multiple keys, many tenants
verify() assumes one secret per provider name, fixed in config/hookguard.php — fine for a
single-tenant app, not for a platform holding a different secret per merchant for the same provider.
For that, resolve settings yourself and call verifyWithSettings() instead. It skips the config
lookup entirely but keeps the same outcome-mapping rules — the only difference is UnsupportedProvider
can never happen, since there's no config list to miss:
$merchant = Merchant::findOrFail($merchantId); // however you look merchants up $result = $hookguard->verifyWithSettings( $request->getContent(), $request->headers->all(), settings: [ 'verifier' => 'hmac', 'secret' => $merchant->stripe_webhook_secret, 'algorithm' => 'sha256', 'signature_header' => 'X-Acme-Signature', ], identifier: "stripe:merchant:{$merchant->id}", );
identifier is never looked up anywhere — it only labels the result ($result->provider) for your
own logging, so a hash_mismatch in your logs tells you which merchant it was for. Everything else
(hash_equals, raw-body verification, Error vs HashMismatch vs Verified) behaves exactly as it
does through verify(), because both call the same internal resolution path.
A note on non-JSON bodies
A correctly signed body that isn't a JSON object or array comes back as Error, not Verified.
This keeps Verified a hard guarantee that payload() works. If you integrate a provider that posts
form-encoded webhooks, open an issue — the signature check itself works fine, it's only the decoding
step that assumes JSON.
Configuration reference
Every key under hookguard.providers is a provider name you pass as the third argument to
verify(). A name that isn't listed can never verify as true.
| Key | Required | Description |
|---|---|---|
verifier |
yes | Which verifier to use: hmac, signed_payload, or a name you registered yourself. |
secret |
yes | The shared secret the provider signs with. Read it from the environment. |
algorithm |
yes | Hash algorithm passed to hash_hmac() — anything in hash_hmac_algos(). Usually sha256. |
signature_header |
yes | The header carrying the signature. Matched case-insensitively. |
timestamp_header |
no | Leave null (or omit) when the timestamp lives inside the signature header as t=…, which is how Stripe does it. Set it to a header name for providers that send it separately. |
A provider whose secret is missing or blank resolves to Error. It is not possible for an
unconfigured or half-configured provider to return Verified.
Built-in verifiers
hmac
The provider sends hash_hmac(algorithm, rawBody, secret) as a lower-case hex digest in one header,
with nothing wrapped around it.
'acme' => [ 'verifier' => 'hmac', 'secret' => env('ACME_WEBHOOK_SECRET'), 'algorithm' => 'sha256', 'signature_header' => 'X-Acme-Signature', ],
signed_payload
Stripe's scheme. The header looks like t=1699999999,v1=abc…, and the string that was actually
hashed is "{timestamp}.{rawBody}".
'stripe' => [ 'verifier' => 'signed_payload', 'secret' => env('STRIPE_WEBHOOK_SECRET'), 'algorithm' => 'sha256', 'signature_header' => 'Stripe-Signature', 'timestamp_header' => null, ],
The timestamp is part of the signed material, so it has to be parsed out and used verbatim or nothing would ever match. Hookguard uses it; it does not evaluate it.
A header may carry several v1= values while a provider rotates its secret. Any one of them matching
is enough, and every candidate is compared in constant time. Signature schemes other than v1 are
ignored.
t= must be a Unix timestamp. A missing or non-numeric t=, or a header with no usable v1=, is an
ordinary failed verification. If you point timestamp_header at a separate header instead, its value
is used exactly as sent — some providers put an ISO-8601 string there.
Writing a custom verifier
Implement Hookguard\Contracts\Verifier. Two helpers are part of the public API and worth using:
Headers::first() for case-insensitive header lookup that copes with both Laravel's array-valued
header bag and plain string maps, and ProviderConfig for reading config keys.
namespace App\Webhooks; use Hookguard\Contracts\Verifier; use Hookguard\Support\Headers; use Hookguard\Support\ProviderConfig; final class AcmeVerifier implements Verifier { public function verify(string $payload, array $headers, array $config): bool { $secret = ProviderConfig::requireString($config, 'secret'); $algorithm = ProviderConfig::requireAlgorithm($config); $headerName = ProviderConfig::requireString($config, 'signature_header'); $provided = Headers::first($headers, $headerName); if ($provided === null) { return false; } // Acme base64-encodes its digest rather than hex-encoding it. $expected = base64_encode(hash_hmac($algorithm, $payload, $secret, binary: true)); return hash_equals($expected, $provided); } }
Register it from your own service provider's boot():
use App\Webhooks\AcmeVerifier; use Hookguard\VerifierRegistry; public function boot(): void { $this->app->make(VerifierRegistry::class)->extend('acme', AcmeVerifier::class); }
Then point a provider at it with 'verifier' => 'acme'. You can also replace a built-in by
registering under hmac or signed_payload, and you can pass a built instance instead of a class
name if your verifier has constructor dependencies.
Two rules keep a custom verifier honest:
- Return
falsefor anything wrong with the request — missing header, malformed header, wrong secret, tampered body. Never throw for those. - Throw for anything wrong with the config.
ProviderConfigdoes this for you. Hookguard catches it and returnsError, so your misconfiguration never masquerades as a mismatch.
Security notes
These are the reason this package exists, so here is the reasoning behind each one.
Always verify against the raw body
A signature covers bytes, not data. Round-tripping a body through json_decode() and
json_encode() changes key order, whitespace, unicode escaping and float formatting — the result
means the same thing but hashes to something completely different, and no signature will ever match.
So pass $request->getContent(). Not $request->all(), not $request->input(), not
json_encode($request->json()->all()). Watch out for anything in the middleware stack that rewrites
or normalises the request body before your controller sees it.
Constant-time comparison
Comparing two signatures with === short-circuits at the first byte that differs. The time that
takes is measurable, and it leaks how many leading bytes of a guess were correct — which is enough to
reconstruct a valid signature one byte at a time over enough requests.
hash_equals() compares every byte regardless. Argument order matters: it is constant-time with
respect to its second argument, so the attacker-controlled value goes on the right.
hash_equals($expected, $provided); // yes hash_equals($provided, $expected); // no $expected === $provided; // absolutely not
Both shipped verifiers do this, and the test suite fails if either one ever stops.
Secrets and signatures stay out of your logs
Configuration errors name the key that is wrong, never its value. A HashMismatch carries no message
at all — describing a near-miss would hand an attacker a hint about how close they got.
A misconfigured provider can never pass
An unknown provider name returns UnsupportedProvider. A configured provider missing its secret, or
naming a verifier nobody registered, or asking for a hash algorithm this PHP build doesn't have,
returns Error. There is no code path on which a typo in config produces Verified.
Testing your integration
Signing a body in your own tests is three lines:
$payload = '{"id":"evt_1","type":"payment_intent.succeeded"}'; $timestamp = (string) time(); $signature = hash_hmac('sha256', $timestamp.'.'.$payload, config('hookguard.providers.stripe.secret')); $this->call( 'POST', '/webhooks/stripe', [], [], [], ['HTTP_STRIPE_SIGNATURE' => "t={$timestamp},v1={$signature}", 'CONTENT_TYPE' => 'application/json'], $payload, )->assertOk();
Use call() rather than postJson() — the latter builds the body for you, and you need control over
the exact bytes.
Contributing
See CONTRIBUTING.md. The short version:
composer test
Security
Found a vulnerability? Please report it privately — see SECURITY.md.
Changelog
See CHANGELOG.md.
License
MIT. See LICENSE.