HTTP message signatures
Partners prove that a request came from them and was not altered or replayed: HTTP Message Signatures (RFC 9421) with hmac-sha256, ed25519, ecdsa-p256-sha256 or ecdsa-p384-sha384, RFC 9530 Content-Digest and RFC 9651 structured fields (an own parser and serializer). Their keys live in the http ring — a partner’s secret can never produce or verify a seal.
Partner keys
use RoundlyConsulting\Sentinel\Enums\Algorithm;
// The partner sends you their public key (PEM or base64:…) — import it, no deploy:
Sentinel::keys()->ring('http')->import('acme-2026-10', Algorithm::Ed25519, $partnerPublicKeyPem, owner: $partner);
// …or you generate the pair and share the public half:
$key = Sentinel::keys()->ring('http')->generate(Algorithm::Ed25519, keyId: 'acme-2027-01', owner: $partner);
$key->publicKey; // base64:… — share it with the partnerImported partner keys are verify-only and bound to the partner model — no deploy per partner. The alternative without the database lists them as verify-only config keys, with the ring reading both drivers — one deploy per partner, and no owner on the key:
SENTINEL_HTTP_KEY_DRIVER=chain
SENTINEL_HTTP_KEYS="partner-ed|ed25519|base64:<32-byte public key>,partner-hmac|hmac-sha256|base64:<shared secret>"A partner’s shared secret is a verify-only key. Set as the http ring’s current key (SENTINEL_HTTP_KEY), it is a key this application signs with, and inbound requests signed with it are refused (unknown_key). List it under the ring’s previous keys or import it verify-only; set accept_signing_keys only when you also sign outbound with that same secret.
Verifying inbound requests
Route::post('/partner/events', PartnerEvents::class)->middleware('sentinel.signed'); // default profile
Route::post('/partner/orders', PartnerOrders::class)->middleware('sentinel.signed:partners'); // a named profile
$signature = Sentinel::signatures()->current($request); // ?VerifiedSignature — what sentinel.signed verified
$partner = Sentinel::signatures()->owner($request); // the key's owner model (database keys), or null
$signature = Sentinel::signatures()->verify($request, 'partners'); // the same check, programmaticThe default profile requires created, keyid and a nonce, accepts signatures up to 300 seconds old (± 30 seconds of clock skew), and requires content-digest whenever there is a body. Add profiles under sentinel.signatures.profiles and name them: sentinel.signed:partners. Place sentinel.signed before auth when signatures authenticate API clients.
@scheme and @authority come from Laravel’s request, so behind a load balancer or proxy they follow your trusted-proxy configuration (X-Forwarded-Proto, X-Forwarded-Host). Trust the proxy, or a request the partner signed for https://api.example.com is rebuilt with the internal scheme and host and fails invalid_signature.
VerifiedSignature carries label, ring, keyId, algorithm, created and expires (Unix seconds), nonce, tag, components and the key’s ownerType / ownerId (null for config keys). verify() returns it or throws HttpSignatureException (401; reason() gives the SignatureRejection, keyId() the claimed key id).
The profile, in order
- Label: the configured one, else the only member, else the first whose tag equals the profile tag, else ambiguous_signature.
- Parameters: keyid, created, nonce when require_nonce, tag when one is configured; expires enforced when present.
- Coverage: the request must execute the method it was sent with (a method override → malformed); the profile components, @query when there is a query and content-digest when there is a body must be covered.
- Time (inclusive): created further in the future than clock_skew → not_yet_valid; older than max_age plus clock_skew → too_old; expires more than clock_skew in the past → expired.
- Key: looked up in the profile’s ring — absent, pending, damaged or retired → unknown_key; revoked → revoked_key; an alg other than the key’s → algorithm_mismatch; outside the allow-lists → algorithm_not_allowed; a key this application can sign with → unknown_key, unless accept_signing_keys.
- Digest: every supported Content-Digest member must match the raw body (digest_mismatch); none supported → unsupported_digest.
- Signature: the base is rebuilt and verified → invalid_signature.
- Nonce: only after a valid signature, remembered for the freshness window → replayed.
Rejections
A rejection answers 401 application/problem+json with code signature_rejected — the precise reason only with app.debug, so there is no verification oracle — and, with signatures.advertise, an Accept-Signature hint. HttpSignatureRejected fires with the precise reason:
| SignatureRejection | Meaning |
|---|---|
missing_signature | No Signature / Signature-Input. |
malformed | Unparsable fields, duplicate components, an overridden method. |
ambiguous_signature | No single label to verify. |
missing_parameter | keyid, created or nonce missing. |
missing_component | A required component not covered or absent. |
unsupported_component | A parameterised or unsupported component. |
unsupported_algorithm | An algorithm RFC 9421 does not register — an alg such as rsa-pss-sha512, or a key such as hmac-sha512. |
unknown_key, revoked_key | Key lookup. |
algorithm_mismatch | An alg parameter other than the key’s algorithm. |
algorithm_not_allowed | The key’s algorithm is outside the profile’s or the ring’s allow-list. |
tag_mismatch | Wrong tag. |
not_yet_valid, too_old, expired | Time window. |
digest_mismatch, unsupported_digest | Content-Digest |
invalid_signature | The signature does not verify. |
replayed | The nonce was already seen. |
Signed multipart requests fail closed
PHP parses a multipart/form-data body into $_POST / $_FILES before Laravel runs and keeps no raw bytes, so the Content-Digest cannot be checked: the request is refused with digest_mismatch, even when the partner signed it correctly. Have the partner send the file as the raw body (Content-Type: application/octet-stream, metadata in covered headers or the query) or as JSON. If multipart is unavoidable, serve that endpoint from a PHP pool with enable_post_data_reading = Off and parse php://input yourself.
Signing outbound requests
Sign with a key of the outbound ring — it must be active and hold private material; an imported partner key never signs:
use RoundlyConsulting\Sentinel\DataTransferObjects\SigningOptions;
use RoundlyConsulting\Sentinel\Enums\DigestAlgorithm;
Http::withSignature('acme-2027-01')->post('https://partner.example/events', $event);
Http::withSignature('acme-2027-01', new SigningOptions(expiresIn: 60, tag: 'acme', includeAlg: true))
->post('https://partner.example/events', $event);
// Any PSR-7 request — here without a nonce, for a peer that does not de-duplicate them:
$signed = Sentinel::signatures()->sign($psrRequest, 'acme-2027-01', new SigningOptions(
components: ['@method', '@authority', '@path', 'content-digest'],
digest: DigestAlgorithm::Sha512,
nonce: false,
));
Sentinel::signatures()->verifyResponse($response, 'partners'); // a signed response (PSR-7 or Laravel client)
Sentinel::signatures()->contentDigest('{"hello": "world"}'); // 'sha-256=:X48E…:'Http::withSignature() signs the final request, after every request middleware and beforeSending() callback (a retry or redirect is signed afresh). It adds Content-Digest when the body is not empty and content-digest is a component, drops @query without a query and headers the request does not carry, and sets created, keyid and a fresh nonce (plus expires, tag and alg when configured). A stream that cannot rewind is buffered into a seekable copy first, built with Guzzle’s PSR-7 HttpFactory, which ships with illuminate/http — no extra runtime dependency. Every SigningOptions field left null falls back to sentinel.signatures.outbound.*; a set one is validated like its configuration counterpart:
| Option | Default | Effect |
|---|---|---|
components | outbound.components | Covered components. |
label | outbound.label (sig1) | The signature’s label. |
expiresIn | outbound.expires_in | Seconds until the expires parameter (none by default). |
tag | outbound.tag | The tag parameter (none by default). |
includeAlg | outbound.include_alg (false) | Send the alg parameter. |
digest | outbound.digest (sha-256) | The Content-Digest algorithm. |
ring | outbound.ring (http) | The ring the key id resolves in. |
nonce | true | false omits the nonce parameter. |
Flat and action forms
use RoundlyConsulting\Sentinel\Actions\Signatures\SignRequestAction;
$signed = Sentinel::signRequest($psrRequest, 'acme-2026-10', new SigningOptions(expiresIn: 60));
$signature = Sentinel::verifyRequestSignature($request, 'partners');
$signature = Sentinel::verifyResponseSignature($clientResponse);
$partner = Sentinel::signatureOwner($request);
$signed = app(SignRequestAction::class)->execute($psrRequest, 'acme-2026-10', new SigningOptions);Interoperability is verified against RFC 9421 Appendix B: B.2.5 (hmac-sha256) and B.2.6 (ed25519) are signed byte for byte and verified, B.2.4 (ecdsa-p256-sha256) is verified, and the RSA vectors are rejected. Not supported: component parameters, @request-target, @query-param, multi-signature policies beyond one selected label and consuming a peer’s Accept-Signature.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.