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, plant an envelope minted through an encrypted cast | yes (an AEAD envelope under its own APP_KEY-derived key binds every field and its row) | yes | KeyIntegrityException → UnknownKey |
| 15 | Database-driver key rows: restore an older envelope (un-revoke a key) | no (an authentic old ciphertext) — mitigation: list the kid in SENTINEL_REVOKED_KEYS, which always wins | no | — |
| 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.
- 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.
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.