Request Signing
HMAC-SHA256 signature verification for incoming webhooks — constant-time comparison, replay-window protection, and Stripe-style combined header support.
Request Signing
When a third-party service (Stripe, GitHub, Twilio, etc.) sends a webhook to your API, you need to verify that the request genuinely came from them and was not forged or tampered with in transit. Request signing solves this: the sender computes an HMAC-SHA256 signature over the request body using a shared secret, and includes it as a request header. Your server recomputes the same signature and compares — if they match, the body is authentic.
Reference: HMAC — RFC 2104 | Stripe — Webhook signatures | GitHub — Webhook payloads
Compile-Time Route Map
#[RequiresSignature] attributes are scanned at compile time by RequestSignatureCompilerPass. At runtime, RequestSignatureMiddleware does a single array lookup — no reflection at request time.
How It Works
Inbound Webhook Request
│
▼ priority 75: RequestSignatureMiddleware
│ ├── Route has #[RequiresSignature]?
│ │ └── NO → skip, continue
│ ├── Read shared secret (env var or secrets manager)
│ ├── Read signature from configured header
│ ├── Replay window configured?
│ │ └── YES → read timestamp header, verify |now - timestamp| ≤ window
│ ├── Recompute HMAC-SHA256 over request body
│ ├── hash_equals(computed, provided)
│ │ ├── MATCH → continue to auth + controller
│ │ └── MISMATCH → 401 + SignatureInvalidEvent firedhash_equals() performs constant-time comparison, preventing timing attacks that could allow an attacker to guess the correct signature one character at a time.
Basic Usage
use Vortos\Security\Signing\Attribute\RequiresSignature;
#[RequiresSignature(
secret: 'env:GITHUB_WEBHOOK_SECRET', // reads from $_ENV['GITHUB_WEBHOOK_SECRET']
header: 'X-Hub-Signature-256', // header GitHub sends
)]
final class GitHubWebhookController
{
public function handle(Request $request): Response
{
$payload = json_decode($request->getContent(), true);
// ...
}
}Set the secret in your environment:
GITHUB_WEBHOOK_SECRET=your-very-long-random-secretReplay Protection
A valid signature proves the request was not tampered with — but without a timestamp check, an attacker who captured a legitimate request could replay it days later. Replay protection rejects any request whose timestamp is outside a configurable window.
#[RequiresSignature(
secret: 'env:WEBHOOK_SECRET',
header: 'X-Signature',
timestampHeader: 'X-Timestamp', // header containing Unix timestamp
replayWindowSeconds: 300, // reject if |now - timestamp| > 5 minutes
)]
final class MyWebhookController { ... }The verifier computes abs(time() - $timestamp) > $replayWindowSeconds and rejects if outside the window.
Stripe-Style Combined Header
Stripe sends both the timestamp and signature in a single header:
Stripe-Signature: t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a05bd445416d498d7d4f2d0Vortos detects this format automatically when the header value contains t= and v1=. It extracts the timestamp and signature, then signs the combined payload: $timestamp . '.' . $body — exactly as Stripe specifies.
#[RequiresSignature(
secret: 'env:STRIPE_WEBHOOK_SECRET',
header: 'Stripe-Signature', // combined header — auto-detected
replayWindowSeconds: 300,
)]
final class StripeWebhookController { ... }No extra configuration is needed — the combined-header format is detected from the header value structure.
Reference: Stripe — Verify webhook signatures
Method-Level Signing
Apply #[RequiresSignature] to individual methods if only some actions on a controller require signature verification:
final class PaymentController
{
// Only Stripe-triggered webhooks need signature verification
#[RequiresSignature(secret: 'env:STRIPE_WEBHOOK_SECRET', header: 'Stripe-Signature', replayWindowSeconds: 300)]
public function stripeWebhook(Request $request): Response { ... }
// Regular authenticated endpoint — no signature check
public function createPayment(Request $request): Response { ... }
}Secret Resolution
The secret parameter supports two forms:
| Form | Resolved As |
|---|---|
'env:MY_SECRET' | Read from $_ENV['MY_SECRET'] at runtime |
'literal-secret-value' | Used as-is (not recommended for production) |
Always use env: in production — it keeps secrets out of compiled container parameters and source code.
Algorithm
The default signing algorithm is sha256, producing HMAC-SHA256. To use a different algorithm (for providers that use sha1):
#[RequiresSignature(
secret: 'env:GITHUB_LEGACY_SECRET',
header: 'X-Hub-Signature', // GitHub's legacy SHA-1 header
algorithm: 'sha1',
)]Supported algorithms are anything PHP's hash_hmac() accepts — sha256 (default), sha1, sha512.
Verifying Request Signing Is Working
Simulate a valid signed webhook with curl:
BODY='{"event":"payment.succeeded","amount":1000}'
SECRET="your-webhook-secret"
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print "sha256="$2}')
curl -X POST https://api.example.com/api/webhooks/payment \
-H "Content-Type: application/json" \
-H "X-Signature-256: $SIG" \
-d "$BODY"Expected: HTTP 200.
Confirm rejection with a wrong signature:
curl -X POST https://api.example.com/api/webhooks/payment \
-H "Content-Type: application/json" \
-H "X-Signature-256: sha256=invalidsignature" \
-d "$BODY"Expected: HTTP 401 with {"error": "Invalid request signature"}.
Confirm rejection with an old timestamp (replay attack):
OLD_TS=$(($(date +%s) - 600)) # 10 minutes ago (outside 5-minute window)
curl -X POST https://api.example.com/api/webhooks/payment \
-H "Content-Type: application/json" \
-H "X-Timestamp: $OLD_TS" \
-H "X-Signature-256: sha256=..." \
-d "$BODY"Expected: HTTP 401.
Troubleshooting
All webhook requests return 401 even with a correct secret.
Confirm the secret in your env matches the secret you used to compute the test signature. Check for invisible whitespace — a trailing newline in the env var is a common cause. Print strlen($_ENV['MY_SECRET']) and compare to the expected length.
Signature mismatch even with the right secret.
The body must be the raw byte string, not a re-encoded form. Some frameworks or middleware read and re-encode the request body (e.g., JSON pretty-printing). SignatureVerifier calls $request->getContent() which returns the raw body — ensure no middleware has replaced the content stream.
Replay window too strict — legitimate retries are rejected.
Stripe and GitHub retry webhooks on failure. If your server is slow or the clock is slightly off, increase replayWindowSeconds to 600 (10 minutes) for production. Keep it under 3600 (1 hour) — longer windows reduce replay protection.
Clock skew causing rejections.
NTP sync on your servers is essential for timestamp validation. Run timedatectl status to confirm NTP is enabled. A 5-second drift will not cause issues; a 5-minute drift will.
IP Filtering
Global and per-route allow/deny lists with full CIDR range support, trusted proxy handling, and compile-time per-route maps — reject bad IPs before any business logic runs.
Password Policy
Configurable password rules — minimum and maximum length, complexity (uppercase, digits, special characters), common-password dictionary check, and opt-in HaveIBeenPwned breach detection.