acquia/http-hmac-php
PHP implementation of HTTP HMAC authentication for securing service-to-service API requests. Sign and verify requests using shared secrets, with support for PSR-7 and framework request objects, making it easy to add tamper-resistant authentication to PHP applications.
This is a library, not a Laravel package. There is no service provider, no config/http-hmac.php to publish, no facade, no artisan command. composer require acquia/http-hmac-php:^6.2 and you are on your own for glue — plan for that in the estimate; the glue is ~60 lines and the key management is the rest.
Where to look first, in this order:
src/Acquia/Hmac/ — the whole library is a handful of classes. Read RequestSigner, AuthorizationHeaderBuilder, RequestAuthenticator, KeyInterface, KeyLoaderInterface. That is the entire mental model.Psr7/Request/Symfony) — pick the PSR-7 path in Laravel. Ignore the Symfony security classes; symfony/security-bundle is only a suggested dependency and pulling it in for HmacToken/HmacAuthenticationEntryPoint is not worth the weight.First use case — sign one outbound request and eyeball the headers. Do this in php artisan tinker before you write any middleware:
$key = new Acquia\Hmac\Key('my-key-id', config('services.partner.secret'));
$signer = new Acquia\Hmac\RequestSigner($key, 'MyRealm'); // confirm arg order in the class
$psr7 = (new \GuzzleHttp\Psr7\Request('GET', 'https://api.partner.test/v1/things?b=2&a=1'));
$signed = $signer->sign($psr7);
dump($signed->getHeaders()); // Authorization, X-Authorization-Timestamp, X-Authorization-Content-SHA256, nonce
If you can see a plausible Authorization value and a timestamp, you are past the hard part. The second half — receiver-side verification — is a Route::post('/webhook', ...) with RequestAuthenticator in front of it, and you should build both halves in the same branch so you can test them against each other.
1. Outbound: a dedicated signed client, not a global middleware.
Do not push an HandlerStack middleware onto every Http:: call in the app — you will end up signing requests to unrelated services. Bind one named client:
// app/Support/PartnerClient.php
final class PartnerClient
{
public function __construct(private \Illuminate\Http\Client\Factory $http) {}
public function client(): \Illuminate\Http\Client\PendingRequest
{
$stack = \GuzzleHttp\HandlerStack::create();
$stack->push(new HmacMiddleware($this->key()), 'hmac'); // signing middleware wrapper
return $this->http->withOptions(['handler' => $stack])
->baseUrl(config('services.partner.base_url'))
->acceptJson();
}
}
For a small number of endpoints, a plain service class that takes a PSR-7 request, signs it, and sends it with GuzzleHttp\Client is honestly less friction than fighting PendingRequest's body lifecycle. Both are fine; pick one and be consistent.
2. Middleware ordering is the whole ballgame.
The signature covers the body hash. So the signing middleware must run after the body is finalized (json(), withBody(), attach()) and before the request goes on the wire. Push it as the outermost middleware, or sign in a terminal .send() wrapper. If you sign first and set a body later, the receiver's body hash check fails and you get a generic 401.
3. Inbound: verification middleware behind a custom guard.
Laravel's Illuminate\Http\Request is not PSR-7. Convert with symfony/psr-http-message-bridge (PsrHttpFactory) — it is a lightweight, already-adjacent dependency — then hand the PSR-7 request to RequestAuthenticator. Wrap it as a middleware that, on failure, returns 401 with no detail and logs the detail server-side.
4. Pluggable key provider = your rotation story.
Implement KeyLoaderInterface::load(string $keyId) returning a Key or null. Back it with config('services.partners') for the small case, or a partner_keys table (key_id, secret encrypted cast, active, rotated_at) once you have more than a couple of integrations. Load by key id from the incoming signature, never by "the one key we know" — that is what makes zero-downtime rotation possible. Cache the lookup (Cache::remember, 5 min) so verification doesn't hit the DB per request.
5. Replay protection is two mechanisms, and you own one of them. The library enforces the timestamp window (reject anything outside the receiver's tolerance). The nonce is only useful if you store it. Add:
if (! Cache::add("hmac:nonce:{$nonce}", true, now()->addMinutes(15))) {
abort(401); // replayed
}
Use add() (atomic set-if-absent), not has() + put(). Shared cache store across all app instances, or the protection is per-pod and effectively nil.
6. Custom signed headers, when a partner demands it.
AuthorizationHeaderBuilder is the extension point for which headers land in the canonical string. Use it when a partner spec requires Content-Type or a custom X-Partner-Id to be signed. Don't extend it speculatively — every custom header is another place canonicalization can drift from the receiver.
7. Testing pattern that actually catches bugs.
Don't mock the signer. Build a fixture: a known secret, a fixed request (method, path, query, headers, body), and an asserted canonical string. Snapshot the resulting Authorization header. When a partner says "your signature is wrong", diffing against that fixture is how you find the trailing-slash or header-casing bug in ten minutes instead of a day. Also test the failure paths — expired timestamp, replayed nonce, unknown key id, tampered body — because those are the branches that rot silently.
AuthorizationHeaderBuilder, RequestSigner, ResponseSigner, HmacToken, HmacAuthenticationEntryPoint, or any Malformed*Exception, every override of a nullable parameter needs an explicit ?Type or you emit deprecations. This bites hardest in exception subclasses, which are only constructed on error paths and therefore never exercised by green test suites. grep -rE "extends (AuthorizationHeaderBuilder|RequestSigner|ResponseSigner|HmacToken|HmacAuthenticationEntryPoint|Malformed)". If you run tests with deprecations promoted to exceptions, this is a hard failure, not a warning.getContents() returns '' and you sign an empty-body hash — silently, with a valid-looking signature. Rewind before hashing, or build the body as a string. Streamed uploads (multipart, file attaches) are the classic failure here.Http::retry() replays the constructed request. If your signer ran once, the retry carries a stale timestamp; if it reruns, the body may be consumed. Either way, wrap retries outside signing and re-sign per attempt, and keep the timestamp window in mind for slow backoffs.+ vs %20; header name casing and whitespace around values; duplicate headers; Host including or omitting a non-default port; trailing slash on the path; Content-Type: application/json; charset=utf-8 vs bare. Log a hash of the canonical string plus the key id on both sides — never the secret, never the full string in production logs.SignatureRejectedException that surfaces key id, timestamp, and canonical hash. That one class saves hours.env() at config-load only, encrypted cast if in the DB, never in logs, never in a queued job payload. Secret distribution and rotation across environments is where this class of integration actually fails; the signing math is the easy part. Decide the rotation procedure (two active key ids, retire the old after the receiver's window) before you ship.psr/http-message is ^1.0 || ^2.0 — adoption won't force a PSR-7 major bump. But your own code that constructs PSR-7 requests must match whichever major you have; mixing v1/v2 in one process is a fatal.How can I help you explore Laravel packages today?