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

MACs with your own secrets

Key rings manage the key: Sentinel generates, stores, rotates and revokes it. Some secrets belong to your application instead — one per webhook endpoint, encrypted on its row and rotated with a grace period, or a secret a partner handed you, often shorter than 32 bytes. Sentinel::macs() signs and verifies with those. It needs no key ring, no table and no configured key, and it stores nothing:

use RoundlyConsulting\Sentinel\Enums\Algorithm;
use RoundlyConsulting\Sentinel\Facades\Sentinel;

$mac = Sentinel::macs()->sign($payload, $endpoint->secret);                      // raw bytes, HMAC-SHA256
$mac = Sentinel::macs()->sign($payload, $endpoint->secret, Algorithm::HmacSha512);

$header = 'v1,'.base64_encode($mac);                                               // the wire format is yours

// Receiving: the current secret first, then the ones still in their grace period.
$index = Sentinel::macs()->verify($payload, base64_decode($signature), [$current, $previous]);

abort_if($index === null, 401);   // 0 = current, 1 = previous

When to use which

You needUse
Sentinel to own the key — generate, store encrypted, rotate, revoke, bind to a senderA MAC ring: keys()->ring($ring)->mac() / verifyMac() (see MACs for your own services)
Your own secret, stored where you store it, any length, several at once during rotationSentinel::macs()

Rules

  • Plain HMAC: HMAC(secret, exact message bytes) with the secret as given — no HKDF, no purpose label — so a peer in any language reproduces it with its standard library.
  • Raw bytes in and out: sign() returns 32, 48 or 64 bytes (hmac-sha256 — the default —, hmac-sha384, hmac-sha512) and verify() takes the raw MAC. Base64, hex or base64url is your wire format.
  • HMAC only: Ed25519 and ECDSA throw AlgorithmNotAllowedException::notHmacForMacs().
  • Any non-empty secret: there is no 32-byte floor (that floor stays on ring keys) and no upper cap (HMAC hashes an over-long key). An empty secret throws InvalidKeyMaterialException::emptySecret() — for every secret in the list, before any MAC is computed, even one after a match. You own secret strength: generate your own secrets from at least 32 random bytes (random_bytes(32)); a short secret you were handed is only as strong as it is.
  • 1 to 8 secrets: an empty list throws InvalidKeyMaterialException::noSecrets(), more than MacsAccessor::MAX_SECRETS (8) ::tooManySecrets(), and an array that is not a list or holds a non-string an InvalidArgumentException.
  • The result is the lowest index whose secret produced the MAC (the same secret twice → the first), or null. A MAC of the wrong length — empty included — is null, never an exception.
  • No early exit: verify() computes and compares, in constant time, the MAC of every secret, never leaves the loop early and picks the index from the accumulated result afterwards — timing reveals neither which secret matched nor whether any did. A wrong-length MAC is compared against zeros of the right length, so it costs what a wrong one does.
  • Rotation is the list: [$current, $previous] while the grace period runs, then [$current]. Sign with the current secret only.
  • Nothing is stored or logged, and refusals never carry a secret, a MAC or the message. The secrets and the MAC are #[\SensitiveParameter] on every layer, the facade’s own frame of a flat call included.

Refusals

Every rule is checked before any MAC is computed. The four Sentinel exceptions carry translated messages (sentinel::messages.macs.*, English and Slovak):

ExceptionWhen
AlgorithmNotAllowedException::notHmacForMacs()sign() or verify() with Ed25519 or ECDSA.
InvalidKeyMaterialException::emptySecret()An empty secret — when signing, or anywhere in the list.
InvalidKeyMaterialException::noSecrets()verify() with an empty list.
InvalidKeyMaterialException::tooManySecrets()verify() with more than 8 secrets.
InvalidArgumentExceptionSecrets that are not a list of strings — string keys, gaps, a non-string element.

Facade, DI and actions

use RoundlyConsulting\Sentinel\Actions\Macs\SignWithSecretAction;
use RoundlyConsulting\Sentinel\Actions\Macs\VerifyWithSecretsAction;

$mac   = Sentinel::signWithSecret($payload, $secret);                                  // flat
$index = Sentinel::verifyWithSecrets($payload, $mac, [$current, $previous]);

$index = $this->sentinel->macs()->verify($payload, $mac, [$current, $previous]);       // an injected SentinelManager

$mac   = app(SignWithSecretAction::class)->execute($payload, $secret);                  // the raw actions
$index = app(VerifyWithSecretsAction::class)->execute($payload, $mac, [$current, $previous]);
MethodReturnsDoes
macs()MacsAccessorsign() and verify() with secrets your application holds.
signWithSecret(string $message, string $secret, Algorithm $algorithm = Algorithm::HmacSha256)stringThe raw MAC (32 / 48 / 64 bytes).
verifyWithSecrets(string $message, string $mac, array $secrets, Algorithm $algorithm = Algorithm::HmacSha256)?intThe lowest index of 1–8 secrets the raw MAC matches, or null — constant time over every secret.

Every call goes through the manager, so a container binding of either action applies everywhere and Sentinel::fake() sees the facade, the accessor and an injected SentinelManager alike.

Replay and symmetry

  • Replay is not covered, as for ring MACs: a MAC proves who sent the bytes, not that they arrive once. MAC a timestamp or a message id along with the payload and de-duplicate on your side — Sentinel’s idempotency keys do that (see Idempotency keys).
  • Whoever verifies holds the secret and can forge a MAC under it. Keep each secret encrypted at rest — an encrypted cast — and give every sender its own.

Testing

Sentinel::macs() is stateless, so Sentinel::fake() does not fake it: sign() and verify() run the real actions — every rule, the same exceptions — and return the real MAC and index, byte for byte. Each call is recorded without the secrets (signWithSecret with the message and the algorithm, verifyWithSecrets with the message, the algorithm and the number of secrets); the MAC handed to verify() is not recorded either, and a refused call records nothing. rejectMacsWithSecrets() makes every verify() return null, so you can test a 401 path without forging a MAC:

use RoundlyConsulting\Sentinel\Enums\Algorithm;

$fake = Sentinel::fake();

$mac = Sentinel::macs()->sign('{"id":1}', $secret);       // the real MAC, byte for byte
$fake->assertMacSigned('{"id":1}', Algorithm::HmacSha256);

$fake->rejectMacsWithSecrets();
$this->postJson('/webhooks/acme', $payload, ['Webhook-Signature' => 'v1,bogus'])->assertUnauthorized();
$fake->assertMacsVerified(matched: false);

assertMacSigned(?string $message = null, ?Algorithm $algorithm = null), assertMacsVerified(?string $message = null, ?bool $matched = null) — true when a secret matched, false when none did, null for either — and assertNoMacsWithSecrets() check the calls; see Testing.

Cross-language test vectors

The package ships tests/Fixtures/macs-vectors.json with M-1…M-8: RFC 4231 test case 2 (the 4-byte key Jefe) for all three hashes, test case 6 (a 131-byte key), a 1-byte key, the Standard Webhooks example (a 24-byte secret) and two 32-byte secrets for a rotation case — computed independently with PHP’s hash_hmac.

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.