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

Key management

Keys live in named rings: default for seals, the ledger and checkpoints; http for message signatures; your own — a financial ring with stronger keys, say, chosen per seal with ring(); and MAC rings (logs, say) whose secrets you share with your own senders (see MACs for your own services). A key id resolves only in its own ring, and the algorithm always comes from the key — never from a stored row or a received parameter.

DriverKeys come fromRotation
config (default ring)SENTINEL_KEY_ID / SENTINEL_KEY (+ SENTINEL_PREVIOUS_KEYS for verify-only keys)sentinel:key:rotate prints the new environment lines
database (http ring)sentinel_keys rows: AES-256-GCM under a key derived from APP_KEY for envelopes only (never the application encrypter), every field bound — an edited, copied or planted row is rejectedsentinel:key:rotate stores the new key; the old one becomes verify-only
chainThe drivers in order (the first signing key wins)per driver

Algorithms and material

AlgorithmMaterialSealsHTTP
hmac-sha256Root secret 32–1024 bytes; HKDF 32-byte subkeysyesyes (raw shared secret)
hmac-sha384Root ≥ 32 bytes; 48-byte subkeysyes—
hmac-sha512Root ≥ 32 bytes; 64-byte subkeysyes—
ed2551964-byte libsodium secret key / 32-byte public keyyesyes
ecdsa-p256-sha256PKCS#8 / SPKI PEM, P-256yesyes
ecdsa-p384-sha384PKCS#8 / SPKI PEM, P-384yesyes

Material is always base64:<standard base64> — a bare string is refused, which blocks passphrases. HMAC roots are 32–1024 random bytes (never PEM, never one repeated byte); Ed25519 is the 64-byte secret key and/or the 32-byte public key; ECDSA is the base64 of the PEM text, with the curve checked against the algorithm. Material never appears in exceptions, logs, events, about, var_dump() or serialized data.

The API

use RoundlyConsulting\Sentinel\Enums\Algorithm;
use RoundlyConsulting\Sentinel\Enums\KeyDestination;

Sentinel::keys()->rings();                                  // ['default', 'http']
Sentinel::keys()->all();                                    // KeyInfo of every key in every ring — never material
Sentinel::keys()->ring()->current();                        // KeyInfo of the default ring's signing key

$ring = Sentinel::keys()->ring('http');                     // KeyRingHandle; a kid of another ring is unknown here
$ring->name();                                              // 'http'
$ring->find('acme-2026-10');                                // ?KeyInfo
$ring->all();
$ring->generate(Algorithm::Ed25519, keyId: 'acme-2026-11', activatesAt: now()->addWeek());   // stored, pending until then
$ring->import('acme-2026-10', Algorithm::EcdsaP256Sha256, $pem, owner: $partner);
$ring->rotate();                                            // RotationResult: current, previous (now verify-only)
$ring->revoke('acme-2026-10', reason: 'Partner offboarded', actor: $admin);
$ring->retire('acme-2025-10');

$lines = Sentinel::keys()->ring()->generate(Algorithm::HmacSha256, destination: KeyDestination::Config)->envSnippet;
  • The defaults differ on purpose. generate() stores the key in the ring’s database store (KeyDestination::Database; a ring without one throws KeyDriverException::readOnly), while KeyDestination::Config returns the environment lines in envSnippet and stores nothing. sentinel:key:generate does the opposite: environment lines unless --database.
  • rotate() rotates in the store the current signing key lives in; the previous key’s signs_until is set to the new key’s activation, so both overlap without a gap. Every other key of the ring that could still sign after that moment — a pending key an earlier activatesAt rotation scheduled, a key added concurrently — gets the same signs_until under row locks, so exactly one key signs at a time; KeyRotated::$previousKeyId names the key that was signing when the rotation committed. A ring that signs with a custom driver’s key is refused (KeyDriverException::customDriver): rotate it in its own store — Sentinel never prints a custom store’s material.
  • A label is valid UTF-8 of at most 191 characters (KeyDriverException::invalidLabel) and, since 1.2, bound into a database key’s envelope.
  • revoke() and retire() change database keys only. A config key throws KeyDriverException::notStoredInDatabase: revoke it by adding ring:kid to SENTINEL_REVOKED_KEYS, retire it by removing it from the ring’s verify-only list — sentinel:key:revoke and sentinel:key:retire print exactly that instead of failing.
  • A revoke reason is required (1–1 000 characters); retire() ends the key’s verification period now, so seals still on it report RetiredKey.
use RoundlyConsulting\Sentinel\DataTransferObjects\GenerateKeyRequest;
use RoundlyConsulting\Sentinel\DataTransferObjects\RevokeKeyRequest;
use RoundlyConsulting\Sentinel\DataTransferObjects\RotateKeyRequest;

$key = Sentinel::generateKey(new GenerateKeyRequest('http', Algorithm::Ed25519, keyId: 'acme-2027-01', owner: $partner));
$rotation = Sentinel::rotateKey(new RotateKeyRequest('http'));
Sentinel::revokeKey(new RevokeKeyRequest('http', 'acme-2027-01', 'Partner offboarded', $admin));
Sentinel::retireKey('http', 'acme-2025-10');
Sentinel::listKeys('http');                                 // null = every ring
Sentinel::findKey('http', 'acme-2027-01');                  // ?KeyInfo
Sentinel::currentKey();                                     // null = keys.default_ring

One rotation per ring at a time

Row locks cover only keys that exist, so a database rotation also holds a per-ring lock on the keys’ connection for its whole transaction — no table, no migration — and two concurrent first rotations of an empty ring can never each store a signing key:

EngineLock
PostgreSQLpg_advisory_xact_lock, the transaction’s first statement after SET TRANSACTION ISOLATION LEVEL READ COMMITTED (a REPEATABLE READ snapshot would predate the wait); released at commit or rollback.
MySQL / MariaDBGET_LOCK('sentinel:keys:<crc32 of database + ring>') before the transaction, RELEASE_LOCK after it; waits up to innodb_lock_wait_timeout, then KeyDriverException (ringBusy).
SQLiteNone — one writer at a time; the losing transaction fails with “database is locked”, is retried and sees the winner’s key.

Inside a transaction of your own, PostgreSQL keeps the lock until your transaction ends but cannot change its isolation level any more: rotate outside it on REPEATABLE READ. The lock orders rotations only — generate() and import() add keys without it. A ring that a race before 1.1.1 left with two signing keys needs no repair: the newest signs, both verify, and the next rotation demotes both. sentinel:key:list shows it — more than one active key without a signs_until means a rotation is due.

Statuses

Statuses follow NIST SP 800-57 — pending → active → verify-only → retired, plus revoked — and the effective status is computed in this order:

StatusWhenSignsVerifies
Revokedring:kid in keys.revoked (always wins), or revokednono
Retiredverifies_until passed, or retirednono (ledger evidence still verifies)
Pendingactivates_at in the futurenono
VerifyOnlysigns_until passed, a previous config key, or public-only materialnoyes
Activeotherwiseyes (with private/secret material)yes

SENTINEL_REVOKED_KEYS (ring:kid,…) revokes a key whatever its driver says — a restored database row can never un-revoke it.

Importing keys

Onboard a partner at runtime — no deploy, the key bound to the partner model:

$ring->import('acme-2026-10', Algorithm::Ed25519, $publicKeyPemOrBase64, owner: $partner, label: 'ACME');
Sentinel::importKey(new ImportKeyRequest('http', 'acme-2026-10', Algorithm::Ed25519, $pem, owner: $partner));
php artisan sentinel:key:import acme-2026-10 --ring=http --algorithm=ed25519 --file=acme.pem \
    --owner-type=partner --owner-id=7

import() stores existing material in the ring’s database store: a partner’s public key (Ed25519 raw or SPKI PEM, ECDSA PEM) or an agreed HMAC secret, bound to the owner model and verify-only — it never signs, not as the ring’s current key and not through Http::withSignature() — unless imported with signing: true (your own key pair moving from the environment into the database; private material is refused without it). The status, the owner and the label are bound into the encrypted envelope — flipping the status column or rewriting the label in SQL makes the row fail its integrity check —, the kid must be free in the whole ring, and KeyImported is dispatched.

Rotation

  • Database ring: rotate() creates a new active key (optionally activating later with activatesAt); the previous key becomes verify-only when the new one activates. Old seals stay Intact; new seals use the new kid.
  • Config ring: sentinel:key:rotate prints the new SENTINEL_KEY_ID / SENTINEL_KEY and the updated SENTINEL_PREVIOUS_KEYS, with the old key verify-only. Rotating from an Ed25519 or ECDSA key to HMAC also prints an empty SENTINEL_PUBLIC_KEY= — apply it, or the old public key stays and the ring refuses to load (an HMAC key has no public half).
  • Then move old seals to the new key with sentinel:reseal — it never launders, and --from-key= limits it to one old kid.
  • Retire a key once nothing uses it (sentinel:key:retire refuses while seals on any ledger connection still do, unless --force); revoke a compromised key at once.

Config and database drivers

The config driver keeps keys out of the database — the recommended setup for seals:

SENTINEL_KEY_ID=default-20261002-k3f9qa
SENTINEL_ALGORITHM=hmac-sha256
SENTINEL_KEY="base64:…"
SENTINEL_PUBLIC_KEY="base64:…"                  # asymmetric keys; alone = verify-only
SENTINEL_PREVIOUS_KEYS="default-20250901-a1b2c3|hmac-sha256|base64:…"   # verify-only, comma-separated

The database driver stores sentinel_keys rows whose authoritative envelope is AES-256-GCM under a key derived from APP_KEY for envelopes only (rotation through APP_PREVIOUS_KEYS supported). It binds every field — ring, kid, algorithm, material, public key, status, dates, owner and, since 1.2, the label — so a row whose plain columns disagree with its envelope raises KeyIntegrityException, fires KeyIntegrityViolated and is treated as unknown. Database keys are only as safe as APP_KEY, so prefer the config driver for the default ring. chain reads its drivers in order — the first signing key wins, and a revoked signing key of one store (a custom one included) is passed over for the next.

Label binding (upgrading from 1.1)

Since 1.2 a database key’s label is bound into its envelope (sentinel.key/2) like its owner: rewriting, clearing or adding a label in SQL makes the key fail its integrity check. Sentinel 1.1 kept the label in a plain column only (sentinel.key/1). After composer update those keys keep working — nothing to migrate — but their labels stay unbound until they are re-sealed:

SettingA sentinel.key/1 key…
require_bound_label off (the default)opens; its label is read from the column, unbound
require_bound_label onfails its integrity check (KeyIntegrityViolated, treated as unknown)

Every key write already re-seals: generating, importing, revoking, retiring and a rotation’s demotion of the previous key all write sentinel.key/2 and bind the label the row holds at that moment. To bind the rest on purpose:

  • Deploy 1.2 to every node first. A 1.1 node cannot open a sentinel.key/2 envelope — every key 1.2 writes or re-seals reads as an integrity failure there, which is also why there is no rollback to 1.1.x once 1.2 has written keys. Avoid key writes (generate, import, rotate, revoke, retire) while 1.1 and 1.2 nodes run side by side.
  • Re-seal, on a node that can write sentinel_keys. Each key prints as ring:kid → "label" before it is written — check every label: the label a row holds when it is re-sealed is the one that gets bound, so trust starts here. A key that fails its integrity check is listed, never re-sealed, and the command exits 1: find out why before going further. Running it again re-seals nothing (already bound).
  • Turn on strict mode: SENTINEL_REQUIRE_BOUND_LABEL=true. From then on a sentinel.key/1 envelope — one restored from an old backup, with a rewritten label — no longer opens.
php artisan sentinel:key:reseal --dry-run     # what would be bound, nothing written
php artisan sentinel:key:reseal               # every ring that stores keys in the database
php artisan sentinel:key:reseal --ring=logs   # or one ring

sentinel:check reports this as key_envelopes: a warning while sentinel.key/1 keys remain (counts per ring, never a kid or label — so sentinel:check --strict exits 1 until you re-seal), a failure when require_bound_label is on while they do, ok once every key is bound. A fresh 1.2 install has no sentinel.key/1 keys and can turn require_bound_label on right away. The re-seal re-reads each row under its lock, so a revocation racing it is never undone, and re-encrypts the envelope under the current APP_KEY.

Verify-only nodes

Servers that must verify but never seal configure the public half only (SENTINEL_KEY_ID, SENTINEL_ALGORITHM, SENTINEL_PUBLIC_KEY, no SENTINEL_KEY) and SENTINEL_AUTO_SEAL=false. They verify everything; an explicit seal() throws NoSigningKeyException.

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.