Data Encryption

The starter kit encrypts a small set of sensitive database values. This document explains which key protects them, how that key relates to APP_KEY, and the exact commands to run when adopting the dedicated key or rotating it.

What is encrypted, and with which key

Data Where Key used
Sensitive settings values (mail.password, storage.spaces_secret, storage.aws_secret, turnstile.secret_key, postman.api_key, apidog.access_token) settings.value DataCrypt (dedicated key)
Two-factor secret and recovery codes users.two_factor_secret, users.two_factor_recovery_codes DataCrypt (dedicated key)
Sessions, signed URLs, cookies, and any app code still calling the Crypt facade APP_KEY (unchanged)

DataCrypt (Lvntr\StarterKit\Support\Encryption\DataCrypt) is a facade with the same API as Laravel's Crypt facade (encryptString(), decryptString(), encrypt(), decrypt()). The only difference is which key backs it.

APP_KEY vs DATA_ENCRYPTION_KEY

These are two independent keys with two independent lifecycles:

  • APP_KEY — Laravel's own key. Backs sessions, cookies, signed URLs, and the Crypt facade. php artisan key:generate regenerates it.
  • DATA_ENCRYPTION_KEY — a dedicated key for the settings and 2FA data listed above. It has its own .env variable and its own commands (encryption:key, encryption:rekey, encryption:health). php artisan key:generate never reads or writes DATA_ENCRYPTION_KEY or DATA_ENCRYPTION_PREVIOUS_KEYS — it touches only APP_KEY.

This split exists because key:generate used to silently break every encrypted setting and every user's 2FA secret the moment it ran (Crypt/Fortify's default encrypter is bound to APP_KEY, and SettingService swallows the resulting DecryptException and returns null, so the failure was invisible). A dedicated key removes that coupling.

Key-resolution contract

This table is the whole safety property. It is implemented by DataEncrypterFactory and enforced by DataCrypt.

State Primary key (writes) Previous-key chain (reads)
DATA_ENCRYPTION_KEY empty (an install that has not adopted the dedicated key yet) APP_KEY DATA_ENCRYPTION_PREVIOUS_KEYS (if any)
DATA_ENCRYPTION_KEY set DATA_ENCRYPTION_KEY DATA_ENCRYPTION_PREVIOUS_KEYS, then APP_KEY last
  • If APP_KEY is not the primary key, it is always appended to the end of the read chain — anything written before adoption stays readable without running any command.
  • The chain is tried in order, deduplicated, with empty entries dropped.
  • A malformed key in the chain raises a RuntimeException rather than being skipped silently. A silent skip would make a value look unreadable mid-rotation and tempt an operator into clearing DATA_ENCRYPTION_PREVIOUS_KEYS — which is permanent data loss.

Doing nothing is a valid choice. An app that never runs encryption:key keeps working exactly as it does today: DATA_ENCRYPTION_KEY stays empty, the primary key stays APP_KEY, and behavior is bit-for-bit unchanged. Nothing about composer update/sk:update forces adoption.

The three commands

Command Purpose Writes to disk?
php artisan encryption:key Generate a new DATA_ENCRYPTION_KEY and preserve the key it replaces in DATA_ENCRYPTION_PREVIOUS_KEYS Yes — .env
php artisan encryption:rekey Re-encrypt every settings/2FA row onto the current primary key Yes — the database (not .env)
php artisan encryption:health Report, per row, which key it needs and whether DATA_ENCRYPTION_PREVIOUS_KEYS is safe to clear No — read-only

encryption:key

php artisan encryption:key
  • Resolves the current primary key from .env (not from cached config), generates a new random key for the configured cipher in memory, writes DATA_ENCRYPTION_PREVIOUS_KEYS with the old primary prepended, and only then writes the new DATA_ENCRYPTION_KEY. That order is deliberate: a crash between the two writes leaves the old key still primary and redundantly listed — never the reverse, which would orphan every encrypted row.
  • .env is read with the same parser the app boots with (phpdotenv), not a regex — a ${VAR}-interpolated assignment (e.g. DATA_ENCRYPTION_KEY=${APP_KEY}) resolves to the actual key material before it is written into DATA_ENCRYPTION_PREVIOUS_KEYS, never as the literal ${APP_KEY} reference. A .env the parser cannot make sense of aborts the command before a key is generated or anything is written — no half-applied rotation — and the parser's own error message is withheld, because it can quote the malformed line back and that line may itself be key material.
  • The file has to be the authority. If the process environment sets one of these keys — or sets a variable that an interpolated value in .env points at — the running app resolves that value, not the one the file states. encryption:key detects the divergence and stops before generating anything, naming the key but never either value. Rewriting .env would not help: the process value would keep winning, and the rotation would retire a key the app never used while dropping the one it did, leaving existing ciphertext unreadable. Unset the process variable (or bring it in line with the file) and re-run.
  • APP_KEY is never read, modified, or re-emitted by this command, under any option.
  • --show prints a freshly generated key to stdout and writes nothing. Use it to inspect what a key looks like without touching .env.
  • --force is required to run in an environment that looks like production, because rotating the key here makes every encrypted value unreadable until encryption:rekey completes. Re-run with --force only once you have a database backup and a maintenance window.
  • The new key is written to .env but never printed. The key it replaces is never printed or logged either — only its source variable name appears in output.

encryption:rekey

php artisan encryption:rekey
php artisan encryption:rekey --dry-run
php artisan encryption:rekey --only=settings
php artisan encryption:rekey --only=two-factor
php artisan encryption:rekey --chunk=200
  • Re-encrypts every settings and 2FA row that decrypts with a non-primary key onto the current primary key. A row that no configured key can read is left untouched and reported — it is never deleted or blanked.
  • --dry-run performs every decrypt attempt and prints the identical summary without writing a single byte.
  • --only=settings or --only=two-factor (comma-separated to combine) limits the run to one surface.
  • --chunk=<n> (default 200, max 2000) controls how many rows are read, locked, and rewritten per round trip.
  • This command belongs in a maintenance window. It re-reads and locks each chunk under a transaction so a concurrent write is not clobbered by a stale rewrite, but a busy production database under a large rekey should not run this outside a planned window.
  • No updated_at is bumped by a rekey — it is a storage-format change, not a business change.

encryption:health

php artisan encryption:health
php artisan encryption:health --json

Read-only — takes no lock, opens no transaction, safe to run against a live database.

Verdicts (exit code is the machine-readable half):

Verdict Exit code Meaning
safe-to-clear 0 Every scanned value reads with the primary key alone; every surface was fully scanned. DATA_ENCRYPTION_PREVIOUS_KEYS can be cleared.
rekey-required 1 At least one value needs a non-primary key. Nothing is lost yet, but clearing the previous-key list now would lose it. Run encryption:rekey.
not-covered 1 A surface is served by an encrypter this kit did not build, or the starter-kit.encryption config block is absent so the configured key is inert. Nothing is lost, but the attribution below does not describe the install's real read/write path, so "safe to clear" cannot be asserted from it. See Surface coverage below.
incomplete 1 A surface could not be fully scanned, so "safe" cannot be asserted — including a cached config whose resolved key chain no longer matches .env/the process environment. Run php artisan config:clear (and re-cache afterwards if you cache config in production) and re-run.
unreadable 2 A value exists that no configured key can read. The key that wrote it is missing from .env and must be added back — never cleared away.
key-error 2 The key chain itself does not resolve; nothing could be attributed.

The verdict only ever downgrades, never upgrades — a false "safe to clear" is the one output of this command that destroys data.

Surface coverage

Both commands report which encrypter actually serves each surface, not just what the kit's own key chain looks like. This matters because the 2FA surface is not necessarily read through the kit: Fortify resolves Fortify::$encrypter ?? Model::$encrypter ?? Crypt, and the kit deliberately does not overwrite an encrypter a consumer set themselves (StarterKitServiceProvider::configureDataEncryption()).

  • encryption:health names the encrypter behind each surface. A surface served by an encrypter the kit did not build is reported as unvouched and the verdict drops to not-covered (exit 1) — the row attribution is still true about the stored bytes, but it says nothing about the path this install reads and writes through.
  • The stale-published-config gap is reported as its own diagnosis. A config/starter-kit.php published before the encryption block exists makes starter-kit.encryption resolve to null, so DATA_ENCRYPTION_KEY is inert and the primary key is silently the APP_KEY fallback — previously this could still read as "safe to clear". It now yields not-covered; re-publish the config (php artisan vendor:publish --tag=starter-kit-config --force) and re-run.
  • encryption:rekey refuses before it reads a single row when a selected surface is unvouched, and names both the surface and the --only= flag that excludes it. A run that was silently narrowed and then reported as a complete rekey is worse than an error: it would rewrite rows onto a key the reading encrypter does not hold, turning every 2FA login into a failed challenge. A run whose selected surfaces are all covered is unaffected.

Adopting the dedicated key on an existing install

An install that has never run encryption:key is not required to. If you choose to adopt the dedicated key, run these four steps in order:

php artisan encryption:key
php artisan config:clear   # if config is cached, rekey would still resolve the OLD primary key
php artisan encryption:rekey
php artisan encryption:health

config:clear sits between the two commands, not after them. encryption:key writes .env, but a cached config keeps serving the previous chain — so a rekey run before the clear re-encrypts every row onto the key that was just retired, or finds nothing to do at all. Clearing first is what makes encryption:rekey see the key it is supposed to move the data onto.

Then, only after encryption:health reports safe-to-clear:

# edit .env by hand and clear the value:
DATA_ENCRYPTION_PREVIOUS_KEYS=
php artisan config:clear
php artisan encryption:health

If you cache config in production, run php artisan config:cache again immediately after each config:clear above.

Do not clear DATA_ENCRYPTION_PREVIOUS_KEYS until the second encryption:health run (after the edit) also reports safe-to-clear. If it reports anything else, put the old value back and investigate — clearing the previous-key list before every row is confirmed on the primary key is what turns a rotation into permanent data loss.

Rotating an already-adopted key

Rotation uses the same three commands, in the same order, with the same maintenance-window requirement:

php artisan encryption:key --force   # --force only needed in a production-like environment
php artisan config:clear             # if config is cached, rekey would still resolve the OLD primary key
php artisan encryption:rekey
php artisan encryption:health

Then clear DATA_ENCRYPTION_PREVIOUS_KEYS by hand and run config:clear followed by encryption:health again to confirm, exactly as in adoption above. Run encryption:rekey inside a maintenance window — a large table under active writes should not be rekeyed mid-traffic even though the command is written to tolerate concurrent reads/writes safely.

Reverting a rollback of this feature

If the encryption code itself is reverted (git revert), DataCrypt stops existing and every call site falls back to Crypt, which is bound to APP_KEY. Any row written with the dedicated key becomes unreadable the moment the revert lands, unless you do this first:

  1. Set DATA_ENCRYPTION_KEY in .env to the exact same value as APP_KEY.
  2. Run php artisan encryption:rekey so every row is re-encrypted onto that shared value.
  3. Only then apply the code revert.

If the rotation was only partially applied (e.g. encryption:key ran but encryption:rekey did not finish), restore DATA_ENCRYPTION_KEY to its previous value and leave DATA_ENCRYPTION_PREVIOUS_KEYS untouched — the chain is tried in order, so data stays readable. Confirm with encryption:health.

See also

  • docs/server-migration-runbook.md — the copy-pasteable checklist for moving an installed app to a new server without losing encrypted data.
  • docs/artisan-commands.md — full command reference.