Part of #184. Depends on #185 (storage + signers).
Goal
Add Altair\Webhooks\Middleware\WebhookVerifyMiddleware \xe2\x80\x94 a PSR-15 middleware that handles the inbound side of the webhook contract. Reads the signature header, verifies it against the configured signer + secret, enforces a timestamp window for replay protection, dedupes by event id, and either short-circuits (already processed) or passes the verified payload to the handler.
Why
The signers + dedupe store (#185) are the foundation; this middleware is where they become operational. Mirrors the role IdempotencyKeyMiddleware (#173) plays in the idempotency epic.
Behaviour matrix
| Situation |
Response |
| Signature header absent |
401 Unauthorized with {error} envelope. |
| Signature mismatch |
401 Unauthorized. |
Timestamp header absent (mode=strict) |
400 Bad Request. |
| Timestamp outside window |
400 Bad Request with outside replay window. |
| Event id seen within dedupe TTL |
200 OK with empty body + Webhook-Replayed: true header. (Idempotent absorb — never 409; the caller has already been told once that delivery succeeded.) |
| Fresh event |
Claim event id; pass through; on success mark delivered. |
| Handler throws |
Release the dedupe claim; re-throw. |
Header names are configurable (constructor params); defaults match Stripe / GitHub conventions:
X-Signature \xe2\x80\x94 carries the signature (format depends on signer; HMAC-SHA256 commonly uses t=<ts>,v1=<hex> or just <hex>).
X-Timestamp \xe2\x80\x94 epoch seconds the event was emitted.
X-Event-Id \xe2\x80\x94 stable identifier for dedupe; when absent, the middleware hashes body || timestamp to synthesise one.
Companion: ActionAwareWebhookVerifyMiddleware
Add the auto-wiring variant in the same PR (mirrors ActionAwareIdempotencyMiddleware from #182):
ActionAwareWebhookVerifyMiddleware reads the resolved Action via altair:http:action, looks for a static webhook() accessor (emitted by the scaffolder when the spec carries webhook: { direction: in }), and configures WebhookVerifyMiddleware per request from the policy. Pass-through when no Action / no accessor / wrong direction.
Shape
final readonly class WebhookVerifyMiddleware implements MiddlewareInterface
{
public function __construct(
private SignerInterface $signer,
private SecretResolverInterface $secrets,
private InboundDeduplicatorInterface $deduplicator,
private ResponseFactoryInterface $responseFactory,
private StreamFactoryInterface $streamFactory,
private string $secretName,
private int $dedupeTtlSeconds = 3600,
private int $timestampWindowSeconds = 300,
private string $signatureHeader = 'X-Signature',
private string $timestampHeader = 'X-Timestamp',
private string $eventIdHeader = 'X-Event-Id',
) {}
public function process(ServerRequest, RequestHandler): Response { ... }
}
Acceptance criteria
Out of scope
- The outbound dispatcher \xe2\x80\x94 separate issue
- The
webhook: spec block \xe2\x80\x94 separate issue
- Replay attack protection beyond timestamp window + dedupe (e.g. nonce ratchets) \xe2\x80\x94 cryptographically unnecessary given the two checks combined
Notes
The signature format is intentionally loose at the contract level: SignerInterface::verify() decides how to parse the header. HmacSha256Signer accepts either bare hex MAC or Stripe's t=<ts>,v1=<hex> form (the parser extracts the v1= component). This keeps the middleware signer-agnostic and lets new signer implementations define their own header format.
Failure responses use {error: "..."} envelopes matching the idempotency package convention. Don't leak which check failed in the error body \xe2\x80\x94 same message ("webhook signature verification failed") for missing-header / wrong-signature / wrong-secret all three. Distinguishing them in the error body helps attackers; logging the distinction server-side is fine.
Part of #184. Depends on #185 (storage + signers).
Goal
Add
Altair\Webhooks\Middleware\WebhookVerifyMiddleware\xe2\x80\x94 a PSR-15 middleware that handles the inbound side of the webhook contract. Reads the signature header, verifies it against the configured signer + secret, enforces a timestamp window for replay protection, dedupes by event id, and either short-circuits (already processed) or passes the verified payload to the handler.Why
The signers + dedupe store (#185) are the foundation; this middleware is where they become operational. Mirrors the role
IdempotencyKeyMiddleware(#173) plays in the idempotency epic.Behaviour matrix
401 Unauthorizedwith{error}envelope.401 Unauthorized.strict)400 Bad Request.400 Bad Requestwithoutside replay window.200 OKwith empty body +Webhook-Replayed: trueheader. (Idempotent absorb — never409; the caller has already been told once that delivery succeeded.)Header names are configurable (constructor params); defaults match Stripe / GitHub conventions:
X-Signature\xe2\x80\x94 carries the signature (format depends on signer; HMAC-SHA256 commonly usest=<ts>,v1=<hex>or just<hex>).X-Timestamp\xe2\x80\x94 epoch seconds the event was emitted.X-Event-Id\xe2\x80\x94 stable identifier for dedupe; when absent, the middleware hashesbody || timestampto synthesise one.Companion: ActionAwareWebhookVerifyMiddleware
Add the auto-wiring variant in the same PR (mirrors
ActionAwareIdempotencyMiddlewarefrom #182):ActionAwareWebhookVerifyMiddlewarereads the resolved Action viaaltair:http:action, looks for a staticwebhook()accessor (emitted by the scaffolder when the spec carrieswebhook: { direction: in }), and configuresWebhookVerifyMiddlewareper request from the policy. Pass-through when no Action / no accessor / wrong direction.Shape
Acceptance criteria
WebhookVerifyMiddlewareimplements PSR-15 with the listed constructor parametershash_equals(verified by a corrupted-signature regression test)200 OK+Webhook-Replayed: trueheader (not409\xe2\x80\x94 the caller's contract is "we processed this once")ActionAwareWebhookVerifyMiddlewarecompanion class lands alongside; constructor-injectable attribute name matchingaltair:http:actionIdempotencyKeyMiddleware)InMemoryDeduplicatorexclusively; no Redis dependency in the test suiteOut of scope
webhook:spec block \xe2\x80\x94 separate issueNotes
The signature format is intentionally loose at the contract level:
SignerInterface::verify()decides how to parse the header.HmacSha256Signeraccepts either bare hex MAC or Stripe'st=<ts>,v1=<hex>form (the parser extracts thev1=component). This keeps the middleware signer-agnostic and lets new signer implementations define their own header format.Failure responses use
{error: "..."}envelopes matching the idempotency package convention. Don't leak which check failed in the error body \xe2\x80\x94 same message ("webhook signature verification failed") for missing-header / wrong-signature / wrong-secret all three. Distinguishing them in the error body helps attackers; logging the distinction server-side is fine.