Announcement
Request signing and Open Policy Agent for Kipchak
Two packages join the Kipchak ecosystem. Request Signing is a middleware that verifies HMAC-signed API calls and incoming webhooks, and it is free. The OPA driver connects Kipchak to Open Policy Agent, so that authorisation decisions are made by policy rather than by controller code. It is part of Kipchak Enterprise.
Both were listed on the public roadmap. Each was tested in three stages: unit tests, then integration tests against the third-party service running in Docker, then a complete Kipchak API running under FrankenPHP worker mode while that service was stopped and restarted.
Request Signing
An API key identifies the caller. It does not show that a request arrived unaltered, or that it is not a copy of a request already processed. A signature establishes both, which is why most payment providers, and an increasing number of SaaS platforms, sign the webhooks they deliver.
kipchak/middleware-auth-hmac verifies three formats:
- Kipchak signatures, for your own clients and services calling your API.
- Stripe webhooks, using the
Stripe-Signatureheader as Stripe sends it. - Standard Webhooks, the open specification used by Svix and other platforms.
Each format is configured as a profile, and profiles are matched to requests by path. One API can therefore accept signed calls from its own clients on /v1 and Stripe webhooks on /webhooks/stripe without additional routing code:
'profiles' => [
'api' => [
'scheme' => 'kipchak',
'keys' => ['billing-service' => env('HMAC_BILLING_SERVICE_SECRET', '')],
'signed_headers' => ['content-type'],
],
'stripe' => [
'scheme' => 'stripe',
'secrets' => [env('STRIPE_WEBHOOK_SECRET', '')],
'paths' => ['/webhooks/stripe'],
],
],
What the signature covers
Many signing schemes cover only the request body. The method, path and query string can then be changed, and a captured signature can be sent to a different endpoint with the same payload. A Kipchak signature covers the method, the path, the query string, a configured list of headers and a hash of the body. Changing any of them invalidates the signature. The host name is excluded, so that requests continue to verify behind load balancers and proxies.
The format is documented line by line, with reference implementations in Python and in shell using openssl, so that a client in any language can produce a signature. Both reference implementations were run against a live API during testing. In PHP, a single call signs an outgoing request:
$request = Signer::kipchak($request, 'billing-service', $secret, ['content-type']);
The same Signer class produces Stripe and Standard Webhooks headers, so an API can sign the webhooks it sends as well as verify the webhooks it receives.
Each signature is accepted once
A valid signature remains valid for anyone who copies it. The middleware therefore records each signed message when it is accepted, keeps the record for as long as the message’s timestamp could still pass verification, and refuses any later copy.
The record must be shared by every worker on every host, and the check must be atomic. With an ordinary cache, checking whether a signature is new and storing it are two separate operations, and identical requests sent at the same moment can all pass between them. We measured this: when 24 processes submitted one signature simultaneously through a standard cache adapter, between 4 and 21 were accepted. The middleware uses Valkey’s SET NX or Memcached’s add instead, each of which performs the check and the write as one operation. In the same test, exactly one request was accepted every time.
Only signatures that verify are recorded, so forged requests cannot fill the store or consume a client’s nonces. If the store becomes unreachable, the middleware returns 503 rather than accepting requests it cannot check. This behaviour can be changed in configuration.
Behaviour in production
- Secret rotation. Several secrets can be valid at the same time for a client or a webhook endpoint, so a secret can be replaced without downtime.
- Stable error codes. Rejected requests receive a stable code, such as
signature_invalid,timestamp_out_of_toleranceorsignature_replayed. An unknown key and an incorrect signature return the same response, so that the response does not reveal which key IDs exist. The details are written to the log; secrets and signatures are not. - Configuration errors stop the API at startup. A missing secret, a secret that is too short or an unknown profile prevents the API from starting, rather than failing on the first request.
- The request body remains available. Controllers can still read the raw body, and Slim’s body parsing continues to work.
Request Signing is free and MIT-licensed:
composer require kipchak/middleware-auth-hmac
The configuration reference is in the Request Signing documentation.
The OPA driver
In many APIs, authorisation rules are written separately in each controller: a role check in one, a tenant check in another, an exception for a support team in a third. Open Policy Agent holds these rules in policies written in Rego, which can be reviewed, tested and changed without redeploying the API. The OPA driver is how a Kipchak API requests a decision:
$allowed = OPA::get()->allow('httpapi/authz/allow', [
'user' => $userId,
'method' => $request->getMethod(),
'path' => $request->getUri()->getPath(),
]);
Access is denied unless the policy grants it
allow() returns true only when the policy returns exactly true. If the rule is undefined, because the policy is not loaded or the path is misspelt, the result is a denial. The values false, null, the string "true" and the number 1 are also denials. An error in a policy therefore cannot grant access.
If OPA cannot be reached, the driver raises an exception rather than returning a default, and the documentation shows how to treat that exception as a denial. Connection failures can be retried. Errors reported by OPA itself are not retried, because they would fail again. When OPA rejects a policy, the exception includes the file, line and column of the error.
Decisions with more detail
evaluate() returns the complete result of a policy, for policies that return reasons for a denial or the fields a caller may see. On request, it also returns OPA’s metrics, provenance and evaluation traces. The driver also manages the policies and data that OPA uses, runs ad hoc Rego queries, and performs partial evaluation, which converts a policy into conditions that can be applied as a database filter.
Secure connections
OPA is usually deployed close to the API, but not always on a trusted network. The driver supports OPA’s bearer token authentication, and TLS with client certificates for OPA’s mutual TLS mode. The tests run against all three configurations, including the cases in which a TLS connection must fail: an untrusted server certificate, and a request without a client certificate. Timeouts default to two seconds, because a decision is made during every request it protects.
The OPA driver is part of Kipchak Enterprise:
composer require kipchak/driver-opa
The configuration reference is in the OPA driver documentation.
Changes to the Valkey and Memcached drivers
Both cache drivers now provide a connection() method that returns the underlying client, for atomic operations that the standard cache interface cannot express. Request Signing uses it for replay protection, and it is available to application code.
Testing under FrankenPHP worker mode also identified a problem with long-running workers. If a Valkey client fails a command while Valkey is unreachable, the client remains unusable after Valkey recovers, until the worker restarts. The Valkey driver now provides reconnect(), which replaces a pool’s client, and Request Signing uses it to recover without intervention. Code that holds a Valkey connection across requests should follow the pattern in the Valkey documentation.
The Memcached driver also corrects a configuration bug. Every pool was given the same name (in most projects, 1), so all pools shared one underlying client, and each pool replaced the servers of the pools configured before it. Each pool now keeps its own servers, and cached items are stored under the API’s name. Because the stored names change, entries cached before the upgrade will not be found after it, and the cache will repopulate as requests are served.
Both packages are available now.