Threat model
Threat model
What it defends against
Forged deliveries. An attacker who can reach your endpoint but does not hold the secret cannot produce a signature that verifies. This is the whole point, and it is as strong as HMAC-SHA256 and your secret handling.
Tampering in transit. The MAC covers the exact bytes, so any modification invalidates it. Covered by tampering tests for every scheme.
Timing side channels on comparison. Every comparison uses hash_equals, which does not
short-circuit on the first differing byte. A === comparison leaks the matching prefix
length through timing, and a patient attacker can walk a forged MAC out of that one byte at
a time.
Timing side channels across a rotation. All configured secrets are evaluated without returning early on a match, so the work done does not depend on which secret verified.
Replay, partially. Timestamp binding gives a delivery an expiry for schemes whose providers support it. Optional single-use enforcement closes the remaining window when enabled against a shared store.
Algorithm confusion and downgrade. Each scheme pins its digest, and the algorithm is never read from the request. GitHub's weaker legacy header is refused rather than accepted as an alternative. Postal's SHA-256 header, when present, is the only one checked — a failed SHA-256 check never falls back to the SHA-1 header, so an attacker who can corrupt the stronger signature cannot force the weaker one to be honoured.
Configuration mistakes failing open. An endpoint with no secret refuses everything rather than accepting everything. An unknown scheme or endpoint name throws rather than falling back to a default. A custom scheme definition that cannot be honoured exactly is rejected at boot instead of silently defaulted.
What it does not defend against
A compromised secret. Anyone holding it can produce valid deliveries. Rotation is the response, which is why it is a first-class feature.
Replay for schemes with no timestamp. GitHub, Shopify, Twilio and Postal bind no time into the signature, so a captured delivery from those providers stays cryptographically valid indefinitely. Single-use enforcement is the only defence available, and it needs a shared store. Postal also sends no delivery id, so the guard falls back to a digest of the signature there.
Replay when single-use enforcement is off. Which is the default. The window bounds a captured delivery's usefulness to its width; it does not remove it.
A malicious provider. A signature proves who sent the payload, not that its contents are true. Validate the payload as you would any input, and do not trust a webhook to tell you an amount, a permission, or a state transition that you could instead read back from the provider's API.
Application-level idempotency. The replay guard refuses a duplicate delivery. It
cannot tell you whether the work the first one triggered actually finished. Key your own
handler on VerifiedWebhook::idempotencyKey().
Denial of service. Verification is cheap, but there is no rate limiting here. Use Laravel's throttle middleware, or your edge.
Anything about transport. Use HTTPS. A signature over plaintext HTTP is still readable and still replayable by anyone on the path.
No hand-rolled cryptography
Every MAC is computed with hash_hmac() and compared with hash_equals() — PHP's own
vetted primitives from the standard hash extension. Asymmetric verification uses
openssl_pkey_get_public() and openssl_verify(). This package adds no cryptographic
construction of its own; it selects a canonical string and delegates.
Concentrating that in Support\Hmac and Support\Rsa is not about the few lines of code.
It is that no scheme can introduce a naive comparison, pick an algorithm off the wire, or
short-circuit the secret loop — those are properties of two auditable files.
Constant-time comparison does not apply on the asymmetric path and is not claimed there: the configured value is a public key, so a timing difference reveals nothing an attacker cannot compute for themselves.
Keeping the secret out of everything else
A secret does not leak by being deliberately printed. It leaks by being incidentally included — an endpoint object reaching a structured log line, a value object closed over by a queued job, a debug dump forwarded to an error tracker. Each of those reaches the object through a different mechanism, so each is closed separately:
| Path | Covered by |
|---|---|
| Stack traces | #[SensitiveParameter] |
(string), interpolation |
__toString() |
print_r, var_dump |
__debugInfo() |
json_encode — JSON log formatters, API responses |
JsonSerializable |
serialize — queue payloads, cache entries |
__serialize() throws |
var_export |
not covered — see below |
serialize() refuses rather than returning a redacted copy, because a redacted copy would
look usable and silently verify nothing, turning a storage mistake into a mysterious outage
later. If you hit that exception, something is putting configuration into a queue payload
or a cache entry; pass the endpoint name instead and resolve the secret where it is used.
var_export() reads properties directly and no magic method can intercept it. It is used
by config:cache, which operates on the raw config array of strings rather than on these
objects, so it does not arise in practice — but it is a genuine hole and there is a test
asserting the real behaviour rather than an aspirational one.
Operator responsibilities
The package cannot check these for you:
- Keep the clock accurate. Every timestamp check compares against the host clock. NTP is a prerequisite for the window, not a nicety.
- Use a shared store for replay protection. On
arrayorfile, each node remembers only its own traffic and a replay merely has to land elsewhere. That is why the feature is off by default rather than on with a warning. - Keep secrets out of version control. Use environment variables.
Secretredacts itself in string casts and dumps, but it cannot help with a value committed to a config file. - Register the middleware ahead of anything that rewrites the body. A normalising middleware upstream changes the bytes the signature covers.
- Configure trusted proxies if you verify Twilio, whose signature covers the request URL.
- Use HTTPS.
Reporting a vulnerability
Use GitHub's private vulnerability reporting on this repository. Please do not open a public issue for a security problem.
This is a best-effort process. There is no dedicated security mailbox, no published response SLA, and no CVE pipeline — claiming any of those when they do not exist would be worse than saying so plainly, because a researcher would rely on them.