Encryption
Envelope encryption, the per-organization master key, the root key that wraps it, and how rotation works.
Envelope encryption
Three layers. Each exists so that compromising one thing does not compromise everything.
SHORTSPAN_MASTER_KEY (root key - environment, never the database)
| wraps
v
master key (MEK) one per workspace, stored wrapped in encryption_keys
| wraps
v
data key (DEK) one per secret, stored wrapped beside the ciphertext
| encrypts
v
the secret value- Algorithm
libsodium's
crypto_secretbox- XSalsa20 for confidentiality, Poly1305 for authentication. Authenticated encryption throughout, so tampered ciphertext fails to decrypt rather than decrypting to garbage.- Key size
32 bytes at every layer. Nonces are 24 bytes, randomly generated per operation and stored alongside the ciphertext.
- Key material in memory
Zeroed with
sodium_memzeroimmediately after use, so a core dump taken a moment later does not contain it.
Why three layers
Each layer answers a specific question.
- A key per secret
Nothing about one secret's ciphertext tells you anything about another's, and a single leaked data key is one secret rather than all of them.
- A key per workspace
Tenants share no key material. A flaw exposing one workspace's master key exposes nothing about any other, and deleting a workspace means destroying one key rather than sweeping a table.
- One root key
Rotating it rewrites one row per workspace, not one row per secret - so rotation stays cheap no matter how much ciphertext exists.
The root key
Configured as SHORTSPAN_MASTER_KEY, and deliberately not APP_KEY.
APP_KEY signs cookies and sessions. It is handled by the web tier, it is leak-prone, and it is expected to be rotatable. A key that roots the secret store can be none of those things. Keeping them separate means a leaked session key is not a total compromise, and APP_KEY can be rotated normally without destroying customer data.
php artisan encryption:generate-keyIn production an unset root key is a hard failure rather than a silent downgrade. Outside production it falls back to APP_KEY so development and CI need no extra setup.
The rewrap migration
Master keys were originally wrapped by APP_KEY. Rows record which key wrapped them - local for the legacy scheme, local-v2 for the dedicated root key - so both resolve correctly and there was no flag day.
php artisan encryption:rewrapRewrites one row per workspace. It never touches secrets, is idempotent and resumable, and reads each row back after writing it - a rewrap that cannot be undone by reading it back is worse than no rewrap at all.
Rotation
Rotating a workspace master key
A new master key is minted and secrets are re-encrypted under it as a background job. Versions are tracked, so which key protects which secret stays answerable, and rotation runs without downtime.
Rotating the root key
Set the new key, run encryption:rewrap, and keep the old key available until it completes. One row per workspace is rewritten; no ciphertext moves.
Rotating a stored credential
That is a product operation, not a cryptographic one - see Secrets and groups. Set the new value, let it propagate, then revoke the old credential at its source.
Verifying it works
The operations health check actually unwraps a key rather than counting rows. A deployment carrying the wrong root key otherwise looks entirely healthy right up until a customer tries to read a secret - which is the worst possible moment to find out.
It also reports how many keys remain on the legacy wrapping, so an unfinished migration is visible rather than silently blocking APP_KEY rotation forever.
KMS and HSM
The code has the seam: an unrecognised wrapping identifier raises rather than guessing, so a KMS-backed root can be added without a migration of existing rows. The implementation is deferred until there is a compliance requirement for it.
Until then, the root key is an environment variable, and the security of your secret store is the security of your secret manager.
Something here wrong or missing? Tell us.