cazicaa/hookguard

Verify incoming webhook signatures from any PSP in Laravel. One call, one result, no magic.

Maintainers

Package info

github.com/cazicaa/hookguard-v2

Homepage

Issues

pkg:composer/cazicaa/hookguard

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

v0.1.0 2026-08-03 23:02 UTC

This package is auto-updated.

Last update: 2026-08-03 23:27:48 UTC


README

CI Latest Version License

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:

  1. Return false for anything wrong with the request — missing header, malformed header, wrong secret, tampered body. Never throw for those.
  2. Throw for anything wrong with the config. ProviderConfig does this for you. Hookguard catches it and returns Error, 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.