Webhook Signature Verification in Laravel
If you’ve ever implemented webhook signature verification and thought “this should work”… welcome to the club.
Most guides show the happy path:
- compute HMAC
- compare signatures
- return 200
But in production, signature verification fails for painfully small reasons: JSON parsing, newline differences, proxy behavior, compressed bodies, or reading the body at the wrong time.
This post shows a Laravel-precise implementation that is safe-by-default, and it walks through 12 ways signature verification fails—so you can debug by pattern, not panic.
The rule that fixes 70% of signature mismatches
Verify the signature against the raw request body.
Not the parsed JSON. Not $request->all(). Not a reconstructed string.
In Laravel, you want:
| |
Everything else is derived—and derivation is where subtle differences creep in.
A solid “done right” implementation (Laravel middleware)
This example assumes a common signed-webhook pattern:
X-Webhook-Timestamp: Unix timestamp (seconds)X-Webhook-Signature: HMAC of{timestamp}.{rawBody}using a shared secret- Optional: multiple signatures (for secret rotation)
Providers differ on header names and how the “signed payload” is built. Keep the structure, change the details.
1) Middleware: verify signature, timestamp tolerance, replay protection
Create app/Http/Middleware/VerifyWebhookSignature.php:
| |
Register the middleware and apply it only to webhook routes:
| |
| |
2) Controller: acknowledge fast, queue the work
| |
12 ways signature verification fails (and how to recognize each)
Use this list like a decision tree. When you see a mismatch, match the symptom to a cause.
1) You signed the parsed JSON, not the raw body
Symptom: works in local tests, fails for real traffic.
Fix: always sign $request->getContent().
2) JSON re-encoding changed whitespace or key order
If you do json_decode() then json_encode(), you will not get the exact same bytes.
Fix: never rebuild the payload you sign.
3) Newline normalization (\n vs \r\n)
Some clients/tools normalize newlines. Some proxies do too. Fix: verify against the raw bytes you received; avoid copying payloads between tools that mutate newlines.
4) Character encoding differences (UTF‑8, emojis, smart quotes)
Payloads with non-ASCII characters can break if you treat them as “text you can safely transform.” Fix: keep payload as bytes; avoid string transformations before verification.
5) You read the body twice (and the second read is empty)
In PHP frameworks, the request body is a stream; some patterns consume it.
Symptom: signature mismatch + logged body hash changes between layers (or becomes empty).
Fix: only read from $request->getContent() once during verification and pass parsed data forward.
6) You used $request->all() (or $request->input()) as the signed content
Those methods produce structured data, not the original payload. Fix: raw body only.
7) Hex vs Base64 mismatch
Some providers send signatures as hex, others as Base64, others prefix versions.
Symptom: your expected signature “looks right” but never matches.
Fix: match the encoding exactly. For Base64 signatures, generate bytes HMAC (hash_hmac(..., true)) then base64_encode().
8) Wrong “string to sign” (missing timestamp, wrong delimiter, different canonicalization)
Many providers sign {timestamp}.{payload} or payload alone.
Fix: replicate the provider’s documented string-to-sign exactly, including punctuation.
9) Timestamp drift or timezone confusion
You can validate timestamp tolerance with Unix time (seconds). Timezones don’t apply to Unix time—but server clock drift does. Fix: ensure your servers use NTP and keep tolerance reasonable (e.g., 5 minutes).
10) Reverse proxies changed headers (or your app reads the wrong header)
Sometimes CDNs/edge proxies strip or rename headers. Some server configs require explicit “pass-through” for custom headers.
Symptom: signature header missing in production but present locally.
Fix: verify your proxy/web server forwards X-* headers intact.
11) Content-Encoding / gzip behavior differs between environments
Some clients send compressed bodies. Some proxies decompress (or don’t).
Symptom: mismatch only behind certain proxies or only for large payloads.
Fix: verify what your app actually receives. Log Content-Encoding and a body hash. If your framework/server presents you the decompressed body, compute signatures on that same representation—or disable compression for webhook endpoints.
12) Secret rotation wasn’t handled
Providers rotate signing secrets. If you only store one secret, you’ll break on rotation day. Fix: support multiple secrets (current + previous) for a rotation window, as shown in the middleware.
Debugging checklist (fast triage)
When a signature mismatch happens, capture these without leaking secrets:
- timestamp header value
- first 16 chars of signature header (prefix only)
Content-TypeandContent-Encodingsha256(rawBody)andstrlen(rawBody)- your computed expected signature prefix (first 16 chars) in a secure environment
Then answer:
- Is the raw body empty? (body stream issue)
- Do hashes differ between your logs and what the provider says they sent? (proxy/encoding)
- Are you using the correct “string to sign”? (timestamp/delimiter)
- Are you matching encoding (hex vs base64) and secret?
Minimal “compute expected signature” helper
| |
Testing locally without fooling yourself
1) Use curl and sign exactly what you send
In a terminal (where you can control bytes), you can sign a payload file:
| |
2) Don’t copy/paste JSON between tools
Postman, browser consoles, and some “API testing” tools can mutate whitespace/newlines. That’s why “it works in Postman” is not a proof.
If you must use Postman:
- sign the payload after it’s finalized
- ensure it sends the exact body you sign (harder than it sounds)
What this looks like in SendPromptly integrations
SendPromptly’s philosophy is: always sign webhooks, always include a timestamp, and always make verification deterministic.
If you’re verifying SendPromptly webhooks:
- map header names (signature + timestamp)
- replicate the documented “string to sign”
- implement tolerance + replay protection
If your verification is failing, the 12 failure modes above usually point to the issue within minutes.
Quick reference: secure defaults
- Verify raw body
- Use
hash_equals - Add timestamp tolerance (e.g., 5 minutes)
- Add replay protection
- Keep webhook handlers fast (queue work)
- Support secret rotation
If you do those six things, you’ll be ahead of most production integrations.