Skip to content
Secrets7 min read

Rotating Laravel's APP_KEY without logging everyone out

APP_PREVIOUS_KEYS lets Laravel 11 and later decrypt with old keys while encrypting with the new one. The order of steps, config:cache, and stored data.

Crunu teamPublished 7 min read

Example. Kiteline is a fictional company.
On this page

Put the new key in APP_KEY and the old one in APP_PREVIOUS_KEYS, then rebuild the configuration cache and restart every PHP process. Since Laravel 11, the framework encrypts with the current key and, when decrypting, tries the current key first and then each previous one, so session cookies and stored encrypted values keep working. Two things make the rotation incomplete if you skip them. With php artisan config:cache, the key lives in the cached configuration, so changing the environment does nothing until you cache again. And anything already encrypted in your database stays encrypted with the old key until you save it again, so the old key can't leave APP_PREVIOUS_KEYS until that's done.

Storing the key and loading it are covered in Laravel environment variables, and getting .env values to every developer and server is the subject of the guide to sharing .env files with a team. This post is the rotation itself.

What APP_KEY protects

Laravel's encrypter uses OpenSSL with AES-256-CBC by default, and "all of Laravel's encrypted values are signed using a message authentication code (MAC) so that their underlying value cannot be modified or tampered with once encrypted" (Laravel: encryption). The key is used for:

  • Every cookie, including the session cookie. That's why the documentation says that changing the key logs out "all authenticated user sessions."
  • Values you encrypt yourself with the Crypt facade or an encrypted model cast, such as third-party tokens stored per user.
  • Session data, if SESSION_ENCRYPT is on. It's off in the default config/session.php.
  • Signed URLs. The routing service provider resolves the signing keys as app.key followed by app.previous_keys (Laravel source), so unsubscribe or download links made with the old key keep validating during a rotation.

Password hashes don't use it. Bcrypt and Argon2 work without an application key, so rotating APP_KEY never affects anyone's password.

Why rotation used to hurt

Before Laravel 11, key rotation meant two things at once: everyone was signed out, and every value encrypted with the old key became unreadable. The Laravel 11 release notes describe the change: you list previous keys "as a comma-delimited list via the APP_PREVIOUS_KEYS environment variable", Laravel "will always use the 'current' encryption key" when encrypting, and falls back to previous keys when decrypting (Laravel 11 release notes).

The resulting pair of variables looks like this:

.env
APP_KEY=base64:NEW_KEY_44_BASE64_CHARACTERS_EXAMPLE_ONLY=
APP_PREVIOUS_KEYS=base64:OLD_KEY_44_BASE64_CHARACTERS_EXAMPLE_ONLY=

The values here are placeholders. A real key is base64: followed by 32 random bytes in Base64. Several previous keys go into one comma-separated value.

The rotation, in order

  1. Generate a new key without writing it into a file: php artisan key:generate --show prints one, or use the Laravel APP_KEY generator in your browser.
  2. Set APP_PREVIOUS_KEYS to the current key (plus any older ones still listed), and APP_KEY to the new one. Change both in the same deploy.
  3. Rebuild the configuration cache with the new environment: php artisan config:cache.
  4. Restart every long-running PHP process: PHP-FPM or Octane workers, queue workers, Horizon, scheduled commands.
  5. Check that an existing session survives, that a fresh login works, and that a stored encrypted value still decrypts.
  6. Re-encrypt stored data, then remove the old key from APP_PREVIOUS_KEYS in a later deploy.

Step 2 matters more than it looks. If the new key goes live before the old one is listed as previous, every request in between fails to decrypt its cookies, and users are signed out exactly as if you'd never used APP_PREVIOUS_KEYS.

More than one server: rotate in two deploys

With a single server, step 2 is atomic enough. With several servers behind a load balancer, or a rolling deploy, there's a window where some servers have the new key and some still run the old configuration. A cookie encrypted by a new server, sent on the next request to an old one, can't be decrypted there, and that user is signed out.

Because Laravel tries every listed key when decrypting, you can close that window with two deploys:

  1. Introduce the new key as a previous key. Keep APP_KEY as it is and add the new key to APP_PREVIOUS_KEYS. Nothing is encrypted with it yet, but every server can now decrypt it.
  2. Swap them. Once every server runs step 1, set APP_KEY to the new key and move the old one into APP_PREVIOUS_KEYS. Servers that haven't picked this up yet still encrypt with the old key, which the new servers also accept.

Each deploy needs its own config:cache and restarts. The same pattern works for queue workers on separate machines, which decrypt payloads encrypted by the web servers.

Laravel's encrypted environment file is a separate matter. php artisan env:encrypt uses its own key, supplied through LARAVEL_ENV_ENCRYPTION_KEY when you decrypt, so rotating APP_KEY doesn't change it, and changing that key doesn't touch your cookies.

config:cache decides when the key changes

Laravel's configuration documentation says that once the configuration is cached, "your application's .env file will not be loaded by the framework during requests or Artisan commands; therefore, the env function will only return external, system level environment variables" (Laravel: configuration). The cache stores the values that config/app.php read at the moment you ran config:cache, including app.key and app.previous_keys.

Two consequences:

  • Changing APP_KEY alone does nothing on a server with a cached configuration. The old key keeps running until you cache again. That's why step 3 is part of the rotation, not of the deploy around it.
  • The cache file holds the key in plain text in bootstrap/cache/config.php. Treat it like a .env file: don't copy it into images or artifacts, and rebuild it on the server rather than in CI.

With Crunu, the environment variables come from the agent, so the cache is built from them: run crunu run --env laravel-app/production -- php artisan config:cache as the deploy step, after saving the new keys. Long-running processes started with crunu run --env laravel-app/production --restart on-change -- php artisan queue:work restart on their own when the environment changes. A queue worker that restarts before you've rebuilt the cache still reads the old cached key, so rebuild the cache first and let the restarts follow.

Re-encrypting what you stored

APP_PREVIOUS_KEYS makes old ciphertext readable. It doesn't rewrite it. A token encrypted three years ago with the key you're retiring is still encrypted with that key, and decrypting it still needs that key in the list.

Laravel's documentation doesn't describe a built-in command for re-encrypting stored data, so plan it as an ordinary data migration:

  • For model attributes with the encrypted cast, read each value and save it again; saving encrypts with the current key.
  • For values you encrypted with Crypt::encryptString, decrypt and encrypt them again in a batch job.
  • Process in chunks, and keep the old key listed until the job has finished everywhere, including backups you may need to restore from.

Only then remove the old key. If a backup older than the rotation might be restored later, keep the old key somewhere you can retrieve it, because its data needs it.

Example. Kiteline is a fictional company.

In Crunu, every save of laravel-app/production is a new signed version in the environment's history, with its author, time and the names of changed fields. If a rotation goes wrong, restoring the previous version brings back the previous APP_KEY and APP_PREVIOUS_KEYS together, and the history keeps the last 100 versions.

When the key leaked

A planned rotation and a leak need different handling. Someone who knows APP_KEY can decrypt every cookie and stored value encrypted with it, and create encrypted cookies your application accepts. Older Laravel versions turned that into remote code execution: CVE-2018-15133 describes it for versions through 5.5.40 and 5.6.29, noting that "the attacker must know the application key" (NVD: CVE-2018-15133).

For a leak:

  • Re-encrypt stored data promptly. Anything still encrypted with the leaked key is readable by whoever gets a copy of it, now or later from a backup.
  • Consider skipping APP_PREVIOUS_KEYS for cookies. With the leaked key still listed, cookies the attacker made keep decrypting. A forced logout is the cheaper side of that trade.
  • Reissue the secrets that were encrypted with it. If you stored third-party tokens with Crypt, assume they're readable and reissue them at their providers.

Rotating API keys without downtime covers the provider side of that last step.

Checklist

Sources