NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Sentinel for Laravel

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 partner

Imported 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, programmatic

The 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:

SignatureRejectionMeaning
missing_signatureNo Signature / Signature-Input.
malformedUnparsable fields, duplicate components, an overridden method.
ambiguous_signatureNo single label to verify.
missing_parameterkeyid, created or nonce missing.
missing_componentA required component not covered or absent.
unsupported_componentA parameterised or unsupported component.
unsupported_algorithmAn algorithm RFC 9421 does not register — an alg such as rsa-pss-sha512, or a key such as hmac-sha512.
unknown_key, revoked_keyKey lookup.
algorithm_mismatchAn alg parameter other than the key’s algorithm.
algorithm_not_allowedThe key’s algorithm is outside the profile’s or the ring’s allow-list.
tag_mismatchWrong tag.
not_yet_valid, too_old, expiredTime window.
digest_mismatch, unsupported_digestContent-Digest
invalid_signatureThe signature does not verify.
replayedThe 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:

OptionDefaultEffect
componentsoutbound.componentsCovered components.
labeloutbound.label (sig1)The signature’s label.
expiresInoutbound.expires_inSeconds until the expires parameter (none by default).
tagoutbound.tagThe tag parameter (none by default).
includeAlgoutbound.include_alg (false)Send the alg parameter.
digestoutbound.digest (sha-256)The Content-Digest algorithm.
ringoutbound.ring (http)The ring the key id resolves in.
noncetruefalse 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 crypto

By 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.