Vortos
Security

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 fired

hash_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-secret

Replay 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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a05bd445416d498d7d4f2d0

Vortos 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:

FormResolved 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.

On this page