---
title: "MACs for your own services — Sentinel for Laravel | Roundly"
description: "Verify HMACs your own services send — logs, events, webhooks — from PHP, Node or any language, with ring keys, rotation, revocation and sender binding."
url: https://roundly-consulting.com/open-source/docs/sentinel-for-laravel/message-macs
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)

# MACs for your own services

A key ring can vouch for messages that are not models or HTTP requests — log entries, queue payloads, webhooks of your own — sent by peers that share a secret with you, in any language. verifyMac() checks the MAC with the key the kid names in that ring and returns its KeyInfo (owner, label — never material), so you can bind the message to its sender:

```php
use RoundlyConsulting\Sentinel\Exceptions\MacVerificationException;

try {
    $key = Sentinel::keys()->ring('logs')->verifyMac($entry['kid'], $payload, $entry['mac']);
} catch (MacVerificationException $exception) {
    report($exception);   // $exception->reason() is a MacRejection
    return;
}

// Bind the key to the sender: one key per sender, its label (or owner) is the sender's identity.
abort_unless($key->label === $entry['service'], 403);
```

Both the label and the owner are bound into a database key’s envelope, so a database writer cannot point a key at another sender by editing its row: the key fails its integrity check and verifyMac() refuses with MacRejection::UnknownKey. On a host upgraded from 1.1, re-seal the keys and turn on require\_bound\_label first (see Key management).

## Computing the MAC

Any peer computes it with a plain HMAC — no Sentinel, no HKDF:

```js
// Node
const mac = crypto.createHmac('sha256', secret).update(payloadBytes).digest('base64url');
```

```php
// PHP without Sentinel
$mac = rtrim(strtr(base64_encode(hash_hmac('sha256', $payload, $secret, true)), '+/', '-_'), '=');
```

A PHP sender holding the ring calls mac() instead — it uses the ring’s current signing key:

```php
$issued = Sentinel::keys()->ring('logs')->mac($payload);   // IssuedMac, made with the ring's current signing key

$issued->keyId;       // send it along with the message
$issued->mac;         // unpadded base64url
$issued->algorithm;   // Algorithm::HmacSha256
```

The wire format is mac = base64url-no-padding(HMAC(raw secret, exact message bytes)) (RFC 4648 §5). The secret is the key’s raw material — the bytes behind its base64: form — used as is, exactly like an RFC 9421 HMAC key, so the peer needs only the secret. The message is hashed byte for byte: normalise it (JSON encoding, Unicode NFC) on the sending side — Sentinel never re-encodes it.

## Rules

- The ring must be configured and must not be one that seals (keys.default\_ring), the ledger (ledger.ring) or HTTP message signatures (signatures.outbound.ring, every profile’s ring) use — SealingMisconfiguredException::notAMacRing. MAC secrets are shared with senders: in a seal or ledger ring a sender could derive the HKDF subkeys and forge seals, and in a signature ring a MAC over a signature base would be a valid HTTP signature. Never point a seal at a MAC ring either — Sentinel cannot tell from the ring alone.
- The kid resolves only inside the ring: a kid of another ring — even with a valid MAC under that ring’s key — is unknown\_key, and a string that is not a valid kid is refused without a lookup.
- Active and verify-only keys verify (a rotated-out config key in previous and an imported verify-only key included); pending, retired and revoked keys — SENTINEL\_REVOKED\_KEYS included — are refused.
- The algorithm comes from the key, never from the message: hmac-sha256, or hmac-sha384 / hmac-sha512 where the ring’s algorithms allow them.
- The encoding is strict: unpadded base64url of exactly the key’s hash length (43 characters for SHA-256, 64 for SHA-384, 86 for SHA-512). Padding, + or /, whitespace, hex, a non-canonical last character, an empty string and a short or long MAC are all malformed\_mac.
- Constant time: once a usable key is found, Sentinel always computes the full HMAC and compares it in constant time before it looks at the encoding and the length — a malformed MAC costs what a wrong one does, and neither a prefix nor an extension of the right MAC passes.

## Refusals

A refusal throws MacVerificationException: reason() is the MacRejection, ring() and keyId() name the ring and the kid as passed. Its message names the ring and the sanitised kid — never the MAC, the message or key material. The checks run in order: ring → kid → status → algorithm → HMAC and compare → encoding and length → match.

| MacRejection | Value | When |
| --- | --- | --- |
| `UnknownKey` | `unknown_key` | No such kid in this ring — also a kid of another ring, an invalid kid, a key row failing its integrity check. |
| `PendingKey` | `pending_key` | activates\_at is in the future. |
| `RetiredKey` | `retired_key` | The key retired. |
| `RevokedKey` | `revoked_key` | Revoked in its store or through SENTINEL\_REVOKED\_KEYS. |
| `UnsupportedAlgorithm` | `unsupported_algorithm` | The key is Ed25519 or ECDSA, not hmac-\*. |
| `AlgorithmNotAllowed` | `algorithm_not_allowed` | The key’s HMAC algorithm is not in the ring’s algorithms. |
| `Malformed` | `malformed_mac` | Not canonical unpadded base64url, or not the full hash length. |
| `Mismatch` | `mismatch` | Well-formed, but not the MAC of this message under this key. |

mac() refuses with NoSigningKeyException (no active key holding its secret — a revoked one included), AlgorithmNotAllowedException::notHmac (the signing key is Ed25519 or ECDSA) or ::forRing (its algorithm is no longer allowed), plus the ring errors above.

## Setting up a MAC ring

A config key for your own PHP senders, a database store for one imported key per external sender:

```php
// config/sentinel.php → keys.rings
'logs' => [
    'driver' => 'chain',
    'drivers' => ['config', 'database'],
    'algorithms' => ['hmac-sha256'],
    'key_id' => env('SENTINEL_LOGS_KEY_ID'),
    'key' => env('SENTINEL_LOGS_KEY'),
],
```

```php
// one verify-only key per sender, bound to it
Sentinel::keys()->ring('logs')->import('billing-2026-10', Algorithm::HmacSha256, 'base64:…', label: 'billing');
```

Rotate a sender by importing its next key, switching the sender over, then retiring the old kid; revoke a leaked one (or list it in SENTINEL\_REVOKED\_KEYS).

## Facade, DI and action

```php
use RoundlyConsulting\Sentinel\Actions\Keys\VerifyMacAction;

$info   = Sentinel::verifyMac('logs', 'billing-1', $payload, $mac);   // KeyInfo, or MacVerificationException
$issued = Sentinel::mac('logs', $payload);                           // IssuedMac

$this->sentinel->verifyMac('logs', $kid, $payload, $mac);             // an injected SentinelManager
app(VerifyMacAction::class)->execute('logs', $kid, $payload, $mac);   // the raw action
```

Under Sentinel::fake() both calls are recorded and keep production’s ring, kid, status, algorithm and encoding checks; fakeVerifiedMac(), rejectMacs() and four MAC assertions script and check them (see Testing).

## Limits

- Replay: a MAC proves who sent the bytes, not that they arrive once. Put a sequence number or timestamp into the MAC’d message and de-duplicate on your side — nonces help (see Nonces and single-use URLs).
- Symmetry: the receiving application — and every sender holding the same key — can produce valid MACs. Give every sender its own key, bound by label or owner, so one sender cannot pass as another; use Ed25519 HTTP signatures where the verifier must not be able to sign.

## Cross-language test vector

The package ships tests/Fixtures/mac-vector.json: secret bytes 000102…1f (base64:AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=), a UTF-8 message with non-ASCII characters (exact bytes in message\_hex) and the MAC Y1UfB\_bFvWHrQqhxf-i-W3nf1\_GipIV2KqQNdu\_aFRk — it uses both - and \_, so a standard-base64 peer fails it — plus eleven malformed encodings. The package’s tests prove it with plain hash\_hmac and with node:crypto.

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