What it detects
Sentinel detects; database grants and row-level security prevent. The two complement each other — Sentinel tells you that a sealed value, a seal or the history behind it was changed outside the application, and refuses to build on it until someone signs the change off.
Actors
| Id | Actor | Capabilities | In scope |
|---|---|---|---|
| A1 | DB writer | INSERT/UPDATE/DELETE on any table incl. sentinel_*; no code execution; no access to env, config or APP_KEY. | yes |
| A2 | DB writer + snapshot restore | A1 + replace tables or the whole database with an older snapshot. | yes (needs anchors for full coverage) |
| A3 | Network/client attacker | Replays, alters or duplicates HTTP requests. | yes |
| A4 | Authorised insider | Uses the app normally (e.g. acknowledges changes). | audited (actor + reason + changed attributes in the MAC’d ledger); authorisation is the host’s job (Gate hook) |
| A5 | Holder of signing keys / APP_KEY (database-driver keys) / RCE / config write | Can forge anything. | out of scope |
Detection matrix
“Window” means the time since the last checkpoint — scheduled every minute by default.
| # | Attack | DB only (no anchor) | With ≥ 1 external anchor | Status / finding |
|---|---|---|---|---|
| 1 | Change a sealed column | yes | yes | Tampered (+ changed attributes via field tags) |
| 2 | Change rows feeding a computed value | yes | yes | Tampered |
| 3 | Copy values + seal from another row / model / seal name / tenant scope / app context | yes (domain separation) | yes | Tampered |
| 4 | Re-point a seal row (sealable_id, seal) | yes | yes | Tampered |
| 5 | Delete a seal row | yes | yes | Missing (seal_deleted when ledger history exists) |
| 6 | Insert an unsealed row (strict seal) | yes | yes | Missing (never_sealed); a lenient seal → Unsealed (by definition not a finding) |
| 7 | Restore an older row + its older seal row | yes, while the newer ledger entries exist | yes | Stale (newer_version) |
| 8 | #7 + delete the newer ledger entries | yes if those entries were already checkpointed (CheckpointMismatch); no inside the window | yes outside the window | ledger findings |
| 9 | Restore the whole database (incl. ledger + checkpoints) to an older snapshot | no | yes (AnchorAhead) for any rollback past the last anchored checkpoint | ledger finding |
| 10 | Delete the checkpoint tail (+ entries) | no | yes | AnchorAhead |
| 11 | Rewrite a ledger entry or checkpoint | yes (entry MAC / checkpoint MAC + chain) | yes | EntryInvalid, CheckpointInvalid, ChainBroken |
| 12 | Delete a sealable row without a tombstone | yes (sentinel:verify --ledger: live ledger head, row gone) | yes | EntityDeleted |
| 13 | Rewrite the stored algorithm / key_id / ring on a seal row | yes (the algorithm comes from the key; ring allow-list; HKDF info binds ring + kid + algorithm) | yes | AlgorithmMismatch / UnknownKey / Tampered |
| 14 | Database-driver key rows: swap ring/kid/algorithm, replace a public key, rewrite, clear or add a label, plant an envelope minted through an encrypted cast | yes (an AEAD envelope under its own APP_KEY-derived key binds every field — the label too, since 1.2 — and its row); a key still in the pre-1.2 envelope binds its label only once re-sealed (sentinel:key:reseal) | yes | KeyIntegrityException → UnknownKey (+ KeyIntegrityViolated) |
| 15 | Database-driver key rows: restore an older envelope (un-revoke a key) — or, on a host upgraded from 1.1, a pre-1.2 envelope whose label is then rewritten | no (an authentic old ciphertext) — mitigation: list the kid in SENTINEL_REVOKED_KEYS, which always wins. The pre-1.2 case: yes once sentinel.keys.require_bound_label is on | no; the pre-1.2 case as DB only | — / with require_bound_label: KeyIntegrityException → UnknownKey |
| 16 | Changes made and rolled back entirely inside the window | no | no | — (shorten the checkpoint interval) |
| 17 | Replay a signed HTTP request | yes (created/expires window + nonce de-duplication) | — | replayed, too_old, expired |
| 18 | Alter the body/headers of a signed request, or add a method override | yes (content-digest + signature; the executed method must be the signed one) | — | digest_mismatch, invalid_signature, malformed |
| 19 | Duplicate a POST (retry storm, double click) | yes (replay / 409) | — | idempotency |
| 20 | Reuse an idempotency key with another payload | yes (422) | — | idempotency |
| 21 | Reuse a single-use URL | yes (atomic consume) | — | 403 |
What it does not guarantee
- Sentinel detects; it does not prevent. A row may be tampered with between two verifications.
- Rows written through the query builder, raw SQL or withoutSealing() are unsealed or stale until re-sealed. Strict seals report them.
- Database-driver keys are only as safe as APP_KEY. The config driver keeps keys out of the database — recommended for the default ring.
- Without an external anchor, a full-database rollback (#9) and checkpoint truncation (#10) are undetectable. With one, a checkpoint never overwrites an anchor the database disagrees with, so after a rollback the anchor keeps the evidence until someone investigates and clears it.
- Verification proves integrity relative to the last authorised seal, not that the authorised value was correct.
- Computed values that read related rows are only as fresh as the last re-seal of the owning model.
- A raw-secret MAC (verifyMac()) is symmetric: whoever can verify it holds the secret and can forge one — the receiving application and every sender holding the same key. Give every sender its own key, bound by label or owner, and use Ed25519 HTTP signatures where the verifier must not be able to sign.
- A pre-1.2 key’s label is trusted from its first re-seal: whatever the label column holds when the key is first re-sealed — by sentinel:key:reseal or any key write — is what gets bound, so check the labels the command prints. Until sentinel.keys.require_bound_label is on, a restored pre-1.2 envelope still opens with whatever label its column holds.
- MACs carry no freshness: verifyMac() proves who sent the bytes, not that they arrive once — put a sequence number or timestamp into the message and de-duplicate.
Out of scope by design: confidentiality (use Laravel’s encrypted casts), an attacker holding the signing keys, APP_KEY (for database-driver keys), the deployed code or the configuration, and intercepting query-builder or raw SQL writes — they are detected afterwards.
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.