# 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.

URL: https://crunu.com/blog/rotate-laravel-app-key
Language: en
Updated: 2026-10-30

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](https://crunu.com/environment-variables/laravel), and getting `.env` values to every developer and server is the subject of the guide to [sharing .env files with a team](https://crunu.com/guides/share-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](https://laravel.com/framework/docs/13.x/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](https://github.com/laravel/framework/blob/13.x/src/Illuminate/Routing/RoutingServiceProvider.php)), 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](https://crunu.com/learn/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](https://laravel.com/framework/docs/11.x/releases)).

The resulting pair of variables looks like this:

```dotenv
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](https://crunu.com/tools/secret-generator/laravel-app-key) 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`.

*Figure: A new variable row in the payments-api production table. The name CHECKOUT_SIGNING_KEY is typed, and the Generate button has just filled the value with a random 40-character string of letters and digits, so nobody has to invent a secret. Add and Cancel finish the row.*

## 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](https://laravel.com/framework/docs/13.x/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.

*Figure: The history of the payments-api production environment at Kiteline, a fictional company: versions 15 to 11, each with its author, time and device, and what changed by variable name, never by value. Version 15 is current. Version 13 is open, and a dialog asks to restore it: Crunu will save a new version 16 with the files of version 13, and version 15 stays in the history.*

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](https://nvd.nist.gov/vuln/detail/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](https://crunu.com/blog/rotate-api-keys-without-downtime) covers the provider side of that last step.

## Checklist

- [ ] New key generated with `key:generate --show` or the generator, not written to a file
- [ ] `APP_KEY` and `APP_PREVIOUS_KEYS` changed in the same deploy
- [ ] `config:cache` rebuilt on the server after the change
- [ ] Every PHP worker restarted
- [ ] Stored encrypted values re-encrypted before the old key is removed
- [ ] `bootstrap/cache/config.php` kept out of images and artifacts

## Sources

- [Encryption, Laravel 13.x documentation](https://laravel.com/framework/docs/13.x/encryption), checked October 30, 2026.
- [Configuration, Laravel 13.x documentation](https://laravel.com/framework/docs/13.x/configuration), checked October 30, 2026.
- [Release notes, Laravel 11.x documentation](https://laravel.com/framework/docs/11.x/releases), checked October 30, 2026.
- [RoutingServiceProvider.php, Laravel framework source on GitHub](https://github.com/laravel/framework/blob/13.x/src/Illuminate/Routing/RoutingServiceProvider.php), checked October 30, 2026.
- [CVE-2018-15133, National Vulnerability Database](https://nvd.nist.gov/vuln/detail/CVE-2018-15133), checked October 30, 2026.
