---
title: "HTTP message signatures — Sentinel for Laravel | Roundly"
description: "Verify partners’ RFC 9421 signed requests, sign outbound calls, check signed responses — partner keys, profiles, rejections and signing options."
url: https://roundly-consulting.com/open-source/docs/sentinel-for-laravel/http-signatures
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Sentinel for Laravel](https://roundly-consulting.com/open-source/docs/sentinel-for-laravel.md)

# 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

```php
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:

```bash
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

```php
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. @status exists for responses only: listing it in a profile’s or the outbound components (or in SigningOptions::$components) is refused as invalid configuration — a response signature always covers it anyway.

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:

```php
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. On an outgoing request @authority (and @target-uri) come from its Host header when it has one — the target authority RFC 9421 means, and what the receiver reads — else from the URI, so a request sent to https://10.0.0.5/… with Host: api.example.com is signed for api.example.com. 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

```php
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);
```

A signed response is verified with the same rules plus @status; it never requires a query or a nonce (a present nonce is still de-duplicated). Its body is read once — the coverage check and the Content-Digest share it — so a streamed response (stream => true, a body that cannot seek) verifies too. Such a body cannot be rewound, so after verification it is consumed: when you still need it, buffer the response first ($psr->withBody(GuzzleHttp\\Psr7\\Utils::streamFor((string) $psr->getBody()))) and verify and read that copy.

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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/sentinel-for-laravel.md)
