skip to content

A telehealth platform's Laravel APP_KEY has leaked; how do you rotate it with APP_PREVIOUS_KEYS, and what stays exposed until the old key is retired?

level: seniorimportance: must knowfreq 45%

answer

  1. new key encrypts, old keys only decrypt
  2. comma-separated APP_PREVIOUS_KEYS
  3. config:cache and queue:restart
  4. re-encrypt stored ciphertext yourself
  5. forged values still verify while listed

basics

~20 s

Set a new APP_KEY and list the leaked one in APP_PREVIOUS_KEYS so sessions, stored ciphertext and signed links keep working. New values use the new key, but anything forged with the leaked key still verifies until you re-encrypt data and remove it.

solid answer

~50 s

Mint a key with `php artisan key:generate --show`, set it as `APP_KEY`, and move the leaked value into `APP_PREVIOUS_KEYS` (comma-separated). Deploy it everywhere, rebuild the config cache and run `queue:restart` so long-lived workers drop the old encrypter. In Laravel 13, encryption always uses the current key, while decryption, cookie checks and signed-URL checks try the current key and then each previous one, so users stay logged in and old ciphertext still reads. For a leak that is a transition, not a fix: while the leaked key is listed, cookies, signed URLs and payloads forged with it are still accepted. So re-encrypt stored ciphertext under the new key (Laravel has no command for it), let encrypted queued jobs drain, then remove the key and accept that remaining sessions end. Rotation cannot protect data the attacker already decrypted.

code

ini · 2 lines
ini
APP_KEY="base64:J63qRTDLub5NuZvP+kb8YIorGS6qFYHKVo6u7179stY="
APP_PREVIOUS_KEYS="base64:2nLsGFGzyoae2ax3EF2Lyq/hH6QghBGLIq5uL+Gp8/w="

go deeper

for a junior

Know that changing APP_KEY logs everyone out and breaks stored ciphertext unless the old key is listed in APP_PREVIOUS_KEYS.

for a middle

Explain that the current key encrypts while decryption, cookie checks and signed-URL checks try each previous key in turn.

for a senior

Run a leak rotation end to end: config cache, worker restart, custom re-encryption, draining encrypted jobs, then retiring the key, and state what it cannot repair.

for a principal

Decide how much data should depend on one app-wide key at all, and when per-record or externally managed keys would shrink the blast radius of a leak.

## What APP_PREVIOUS_KEYS does Laravel 13's `config/app.php` reads a comma-separated list from `APP_PREVIOUS_KEYS` into `app.previous_keys`. `EncryptionServiceProvider` passes that list to the encrypter's `previousKeys()` method, which validates each key's length against `app.cipher`. From then on the encrypter: - **encrypts only with the current `APP_KEY`**; - **decrypts by trying the current key first, then every previous key**, until one validates the MAC or tag. The same list is honoured elsewhere. The cookie middleware validates each cookie's HMAC prefix against all keys, and the URL generator's key resolver returns the current key plus the previous keys, so signed URLs issued before the change still verify. That is what makes a routine rotation invisible to users. ## What breaks without it Changing `APP_KEY` with no previous keys has a predictable blast radius: - every **encrypted cookie**, including the session cookie, fails to decrypt and is discarded, so every user is logged out; - values stored with `Crypt::encryptString()`/`encrypt()` throw `DecryptException`; - queued jobs implementing `ShouldBeEncrypted` that were pushed before the change fail in the worker; - **signed URLs** already sent by email stop verifying. Password hashes are unaffected, because the hashing drivers do not use `app.key`. ## A leaked key changes the goal In a routine rotation the old key is merely retired. In a **leak** the old key is hostile: anyone holding it can produce a cookie, a signed URL or an encrypted payload that the application accepts. While the leaked key sits in `APP_PREVIOUS_KEYS` all of these are still accepted: - forged cookies (the prefix and MAC both validate against the previous key); - forged signed URLs, such as a download link for a patient document; - crafted payloads handed to `decrypt()`, which unserializes whatever the MAC admitted. So `APP_PREVIOUS_KEYS` buys time to migrate, and the migration has to finish. ## The procedure 1. **Mint the new key**: `php artisan key:generate --show`, stored in the secret store, not generated per server. 2. **Deploy the pair**: `APP_KEY=<new>` and `APP_PREVIOUS_KEYS=<leaked>` on every web server and worker, then `php artisan config:cache` so the cached config holds both, and `php artisan queue:restart` because a worker keeps its encrypter singleton for its whole life. 3. **Re-encrypt stored ciphertext**. Laravel ships no re-encryption command. Write a chunked job that reads each ciphertext column, calls `decryptString()` (which may use the old key) and writes `encryptString()` (which always uses the new key). 4. **Drain** encrypted queued jobs pushed before the switch. 5. **Retire the leaked key**: remove it from `APP_PREVIOUS_KEYS`, redeploy, re-cache. Anyone still on an old session cookie is logged out, which after a leak is usually what you want. ## What rotation cannot fix | Exposure | Fixed by rotation? | |---|---| | New cookies, signed links, ciphertext | Yes, from step 2 | | Forgeries made with the leaked key | Only after step 5 | | Ciphertext the attacker already copied | No: it stays readable with the leaked key forever | | Third-party credentials stored encrypted | No: rotate them at each provider | On a telehealth platform, any encrypted clinical data that left the building with the key must be handled as a disclosed-data incident, not as something rotation repairs. ## Common mistakes during the switch - **Generating the key on each server.** Servers then disagree, and users bounce between logged-in and logged-out depending on which server answers. - **Forgetting the config cache.** A deploy that edits the environment but keeps an old cached config keeps encrypting with the leaked key. - **Listing a key of the wrong length.** `previousKeys()` validates every entry against the cipher, so a malformed previous key stops the encrypter from being built at all.

  • Why must queue workers be restarted after changing APP_KEY or APP_PREVIOUS_KEYS?
    A worker is a long-running process: it resolved the `encrypter` singleton at start-up and keeps using that instance, with the old key list, until it exits. `php artisan queue:restart` tells workers to exit after their current job so the process manager starts them with the new config.
  • Why not simply leave the old key in APP_PREVIOUS_KEYS for good after a routine, non-leak rotation?
    Every listed key is still a key that can forge cookies and signed URLs, so the list only widens what must stay secret. It also hides unfinished migrations: ciphertext under the old key keeps working until someone removes the key and discovers it. Re-encrypt, then retire.
  • Does rotating APP_KEY force users to reset their passwords?
    No. Password hashes are produced by the hashing drivers, which do not use `app.key`. Rotation affects encrypted and signed values only. After a leak you may still force a logout by retiring the old key, but credentials themselves are untouched by the rotation.

It is like re-keying a clinic after a master key was copied: you hand staff new keys but leave the old lock cylinder working until every cabinet has been moved to the new locks. That keeps the day running, yet whoever copied the old key can still open anything on the old cylinder until you pull it.

saying these in an interview costs you the question

  • APP_PREVIOUS_KEYS re-encrypts old data with the new key automatically.
  • Listing the leaked key as a previous key is a complete fix.
  • New values are encrypted with all listed keys during the transition.
  • Rotating APP_KEY makes previously exfiltrated ciphertext unreadable.
  • Queue workers pick up a new APP_KEY on their next job without a restart.