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:
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:
// Node
const mac = crypto.createHmac('sha256', secret).update(payloadBytes).digest('base64url');// 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:
$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::HmacSha256The 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:
// 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'),
],// 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
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 actionUnder 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 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.