corgspace / hmac-http-client
HMAC-signed HTTP client middleware for Laravel server-to-server APIs
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.0
- guzzlehttp/psr7: ^2.0
- illuminate/http: ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^11.0 || ^12.0 || ^13.0
- psr/clock: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- phpunit/phpunit: ^10.5 || ^11.0
README
Laravel package that adds HMAC-signed HTTP requests to the built-in HTTP client via an Http::hmac('service_name') macro. Each outgoing request gets X-Key-Id, X-Timestamp, X-Nonce, and X-Signature headers added automatically; the caller supplies X-Idempotency-Key and the body.
Install
composer require corgspace/hmac-http-client php artisan vendor:publish --tag=hmac-http-client-config
Configure
Add a service to config/hmac-http-client.php:
'services' => [ 'example_api' => [ 'base_url' => env('EXAMPLE_API_URL'), 'key_id' => env('EXAMPLE_API_KEY_ID'), 'secret' => env('EXAMPLE_API_SECRET'), 'secret_encoding' => env('EXAMPLE_API_SECRET_ENCODING', 'base64'), ], ],
.env:
EXAMPLE_API_URL=https://api.example.com
EXAMPLE_API_KEY_ID=my-app-prod
EXAMPLE_API_SECRET=<base64- or hex-encoded secret>
secret_encoding is base64 (default), hex, or raw. Decoded secret must be at least 32 bytes.
Use
use Illuminate\Support\Facades\Http; $response = Http::hmac('example_api') ->withHeaders(['X-Idempotency-Key' => $operationId]) ->post('/v1/resource', [ 'external_ref' => $externalId, 'source' => 'direct', ]); if ($response->successful()) { $data = $response->json(); }
The macro returns a PendingRequest with acceptJson()->asJson() already applied. Chain any normal HTTP client method after Http::hmac(...).
The caller must set X-Idempotency-Key. It is part of the signed canonical and should be meaningful to the upstream (a webhook event ID, a logical operation ID, etc.). For read-only calls with no natural key, generate a fresh UUID per call.
Retries
Laravel's built-in retry re-signs on every attempt — fresh nonce and timestamp, same idempotency key:
Http::hmac('example_api') ->withHeaders(['X-Idempotency-Key' => $operationId]) ->retry(3, 100) ->post('/v1/resource', $payload);
Canonical string format
For implementers of the verifier side, or anyone debugging a signature mismatch:
{METHOD}\n{REQUEST_TARGET}\n{TIMESTAMP}\n{NONCE}\n{IDEMPOTENCY_KEY}\n{hex(sha256(BODY))}
- No trailing newline. Separators are single
\n(0x0A), never\r\n. METHODuppercased.REQUEST_TARGETis the full request target as it appears on the wire — path plus query string, in the exact order the client sends it. Matches PSR-7'sRequestInterface::getRequestTarget(). Examples:/v1/users,/v1/search?q=widget&sort=asc. Empty targets are normalized to/by PSR-7.BODYis the raw request body bytes. Empty body hashes toe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.- Signature is
base64(hmac_sha256(canonical, secret_bytes)).
Verifier must read the request target from the same source (e.g. the raw request line) and apply the same method-case rules. Any reordering or reformatting of query parameters on either side will invalidate the signature.
Testing
composer test # phpunit composer analyse # phpstan (level max, larastan) composer format-test # pint --test
composer format applies pint fixes in place.
Changelog
See CHANGELOG for a list of recent changes.
Contributing
Contributions are welcome. Please open an issue or pull request at github.com/CorgSpace/hmac-http-client. Run composer test, composer analyse, and composer format-test before submitting.
Security
If you discover a security vulnerability, please report it privately via GitHub's private vulnerability reporting rather than opening a public issue. See SECURITY.md for details.
License
MIT — see LICENSE.