# How to Rotate a Leaked Laravel APP_KEY Without Breaking Your App

> A leaked APP_KEY is not just an encryption problem, it can become remote code execution. But rotating it carelessly logs out every user and can permanently destroy encrypted data. Here is how APP_PREVIOUS_KEYS lets you rotate the key gracefully, what actually breaks, and the exact steps to re-encrypt your data and cut off an attacker.

**Author:** Matt King | **Published:** July 26, 2026 | **Category:** Security

---

You pushed a commit, opened an old branch, or read a support ticket, and there it is in plain text: your production `APP_KEY`. Now you have two bad options in front of you. Leave the key in place and hope nobody noticed, or change it and risk logging out every user and shredding your encrypted database columns.

Neither is acceptable, and neither is necessary. Since Laravel 11 there is a clean way to rotate the key that keeps users logged in and keeps your encrypted data readable, as long as you do it in the right order. This guide walks through what the key actually touches, how to measure your blast radius before you change anything, and the exact rotation procedure that cuts off an attacker without taking your app down.

**Short version:** put the current key into `APP_PREVIOUS_KEYS`, generate a new `APP_KEY`, deploy, re-encrypt any stored ciphertext, and then remove the old key. For a confirmed leak you also need to retire that previous key quickly, because it stays valid while it is in the list.

---

## Why a leaked key is not just an encryption problem

It is tempting to think of the `APP_KEY` as protecting privacy: if it leaks, someone can read your encrypted cookies. That undersells it badly.

Laravel accepts any incoming cookie whose signature verifies against the `APP_KEY`. The session cookie, and every encrypted cookie, carries a message authentication code the framework checks before it decrypts and deserializes the contents. An attacker who holds your key can produce a cookie that passes that check, containing a serialized PHP object of their choosing. When Laravel unserializes it, that object can kick off a property-oriented gadget chain in a library somewhere in your `vendor` tree and end in code execution on your server. We covered the mechanics of that in detail in [the APP_KEY exposure and RCE write-up](/blog/laravel-app-key-rce-exposure).

So a leaked key is not a "rotate it when convenient" secret. It is a live path from your inputs to your shell. Treat the exposure as an incident, and treat the rotation as the fix.

---

## Step one: measure the blast radius

Before you touch anything, find out what actually depends on the key. The answer decides whether this is a five minute job or a careful migration.

The key is used in four places, and they are not equally painful:

| What uses APP_KEY | What breaks on rotation | Recoverable? |
| --- | --- | --- |
| Encrypted cookies and the session cookie | Users are logged out | Yes, automatically, if you use `APP_PREVIOUS_KEYS` |
| `Crypt::encrypt` and `encrypted` casts | Stored ciphertext cannot be decrypted | Only if you keep the old key and re-encrypt |
| Signed and temporary signed URLs | Outstanding links (email verification, unsubscribe) stop verifying | Yes, links regenerate on next send |
| Custom encryption you wrote by hand | Depends entirely on your code | Depends on your code |

Notice what is not on the list. Sanctum personal access tokens are hashed with SHA-256, not encrypted with the key, so they are unaffected. Passport signs its tokens with a dedicated RSA key pair in `storage`, so it is independent of `APP_KEY`. Password hashes come from bcrypt or Argon2id and never touch the key. If your app has none of the four items above beyond ordinary sessions, rotation is low risk and the worst case is a wave of logouts.

The row that ruins weekends is the second one. Run a quick grep to find it:

```bash
grep -rn "Crypt::encrypt\|'encrypted'\|=> 'encrypted:" app/
```

Also check your model casts for `encrypted`, `encrypted:array`, `encrypted:json`, and `encrypted:collection`. Every column behind one of those was written with the old key. If you find any, you are doing a migration, not a swap, and the steps below matter.

---

## Why the naive rotation hurts

The instinct is to run one command:

```bash
php artisan key:generate
```

On its own, `key:generate` overwrites `APP_KEY` in your `.env` and throws the old value away. Nothing remembers the previous key. The moment the new key is live:

- Every session cookie fails its signature check, so every user is logged out.
- Every value in an `encrypted` column was written with the old key, which no longer exists anywhere, so `Crypt::decrypt` throws `DecryptException` and the data is unreadable.

That second point is the one that turns a routine rotation into data loss. There is no undo once the old key is gone and the ciphertext is all you have left.

---

## APP_PREVIOUS_KEYS: rotate without the outage

Laravel 11 added graceful key rotation, and it is the whole reason this can be painless. There is a `previous_keys` entry in `config/app.php` that reads a comma separated list from the environment:

```php
// config/app.php
'previous_keys' => [
    ...array_filter(
        explode(',', env('APP_PREVIOUS_KEYS', ''))
    ),
],
```

The rule Laravel follows is simple. It always encrypts new data with the current `APP_KEY`. When it decrypts, it tries the current key first, then walks through each key in `APP_PREVIOUS_KEYS` until one works. That single behaviour is what lets old cookies and old ciphertext keep working after you introduce a new key, and it is what makes a zero-downtime rotation possible.

---

## The safe rotation procedure

Here is the sequence that keeps users logged in and keeps encrypted data readable. Do it in this order.

**1. Copy the current key into the previous keys list.** Take the value currently in `APP_KEY` and set it as `APP_PREVIOUS_KEYS`. Do this by hand so nothing is lost, since `key:generate` will not do it for you.

```bash
# .env, before generating anything
# APP_KEY=base64:OLDKEYVALUE...
APP_PREVIOUS_KEYS=base64:OLDKEYVALUE...
```

**2. Generate the new key.** Now let Laravel mint a fresh `APP_KEY`:

```bash
php artisan key:generate
```

Your `.env` now has a new `APP_KEY` and the old one preserved in `APP_PREVIOUS_KEYS`.

**3. Deploy and clear config cache.** Push the change to every server and process that runs your app, including queue workers and scheduler, so they all share the same key material. If you cache config, rebuild it:

```bash
php artisan config:clear
php artisan config:cache
```

At this point new sessions and new encrypted values use the new key, while old cookies and old ciphertext still decrypt through the previous key. Users stay logged in. Nothing has broken.

**4. Re-encrypt stored data.** Any `encrypted` column is still sitting there in old-key ciphertext. Rewrite each row so it is re-encrypted with the current key. A one-off command is the safest way, because reading and re-saving an attribute runs it through the current key on the way back in:

```php
// app/Console/Commands/ReEncryptSecrets.php
public function handle(): int
{
    Account::query()->chunkById(500, function ($accounts) {
        foreach ($accounts as $account) {
            // Reading decrypts with the previous key,
            // saving re-encrypts with the current key.
            $account->api_secret = $account->api_secret;
            $account->saveQuietly();
        }
    });

    $this->info('Re-encryption complete.');

    return self::SUCCESS;
}
```

Run it, verify a sample of rows decrypt cleanly, and only then move on. Do this for every model and column you found in the grep from earlier.

**5. Retire the old key.** Once every encrypted value has been rewritten and enough time has passed for old session cookies to expire, remove the old key from `APP_PREVIOUS_KEYS` and deploy again. The rotation is now complete and only the new key exists.

---

## The catch that matters for a real leak

There is a security subtlety in step five that a lot of guides skip, and it is the most important part when the key genuinely leaked rather than being rotated on a schedule.

While the old key sits in `APP_PREVIOUS_KEYS`, it is still a valid key. Laravel will happily accept a cookie signed with it. That is exactly the behaviour you want for a smooth rotation, but it means an attacker who already has the leaked key can keep forging valid cookies for as long as it remains in the previous list. The graceful window that protects your users also protects the attacker.

So the calculus changes depending on why you are rotating:

- **Planned rotation, no known compromise.** Keep the old key in `APP_PREVIOUS_KEYS` as long as you need to migrate data comfortably. There is no adversary racing you.
- **Confirmed or suspected leak.** Migrate fast and drop the previous key as soon as the encrypted data is re-encrypted. If you have no encrypted columns to preserve, do not use `APP_PREVIOUS_KEYS` at all. Rotate straight to the new key and accept that every user is logged out. A wave of logins is a small price for immediately invalidating every cookie the attacker could forge.

In an incident, forcing everyone to re-authenticate is a feature, not a regression.

---

## If you are still on Laravel 10 or older

`APP_PREVIOUS_KEYS` does not exist before Laravel 11, so there is no built-in fallback. Your options are to accept the logout and data loss, or to write a migration script that decrypts every encrypted value with the old key explicitly and re-encrypts it with the new one before you flip `APP_KEY`. You can construct an `Illuminate\Encryption\Encrypter` instance with a specific key to read the old ciphertext:

```php
$old = new Illuminate\Encryption\Encrypter(
    base64_decode(substr($oldKey, 7)), // strip the "base64:" prefix
    config('app.cipher')
);

$plaintext = $old->decrypt($ciphertext);
```

Read with the old encrypter, write with the app default, then swap the key. It is more manual, and it is one more reason to get onto a supported Laravel release. The [Laravel security checklist for 2026](/blog/laravel-security-checklist-2026) covers keeping your framework current alongside the other controls that matter here.

---

## Stop the next leak before it happens

Rotating the key fixes today's exposure. It does nothing about the next one, and the next one usually comes from the same handful of mistakes.

Keys leak because a `.env` file gets committed to a repository, because the file is reachable over HTTP at `/.env` on a misconfigured server, because a production error page renders the environment in a stack trace, or because the value ends up in a log line or an unencrypted backup. Each of those is preventable. Make sure `.env` is in your `.gitignore`, confirm `APP_DEBUG` is `false` in production, and verify the file cannot be fetched from the web. We go deeper on the exposure paths in [why an exposed .env file is so dangerous](/blog/laravel-env-file-exposed) and on the cookie side in [Laravel session cookie security](/blog/laravel-session-cookie-security).

The hard part is knowing whether any of those doors are currently open on your live site, because from the outside is exactly where an attacker looks. StackShield scans your application externally, the same way an attacker would, and flags an exposed `.env`, a leaking debug page, and the misconfigurations that turn a stray secret into a breach. [Run a free scan](/free-scan) and find out what your production environment is giving away before someone else does.

---

## Frequently Asked Questions

### What does the Laravel APP_KEY actually protect?

The APP_KEY is the secret Laravel uses for symmetric encryption. It encrypts and signs your cookies (including the session cookie), it powers Crypt::encrypt and any Eloquent attribute you mark with the encrypted cast, and it signs temporary and signed URLs. It does not protect Sanctum tokens (hashed with SHA-256), Passport tokens (signed with a separate RSA key pair), or password hashes (produced by bcrypt or Argon2id). Knowing exactly which of these you use tells you what will break when the key changes.

### Will rotating APP_KEY log all my users out?

On Laravel 10 and earlier, yes. Session cookies are encrypted with the key, so changing it makes every existing cookie undecryptable and every user is logged out on their next request. On Laravel 11 and newer you can avoid this by moving the old key into APP_PREVIOUS_KEYS before you generate a new one. Laravel encrypts new data with the current key but will still decrypt old cookies using the previous key, so sessions survive the rotation.

### Can I lose data by changing APP_KEY?

Yes, and this is the real danger. Anything stored with Crypt::encrypt or an encrypted cast, such as encrypted database columns holding tokens, API secrets, or personal data, was written using the old key. If you change the key with no fallback and no re-encryption step, that ciphertext can no longer be decrypted and the data is effectively gone. Always inventory your encrypted columns first, keep the old key available through APP_PREVIOUS_KEYS, and re-encrypt the data before you retire the old key.

### Why is a leaked APP_KEY a remote code execution risk?

Laravel trusts any cookie that carries a valid signature derived from the APP_KEY. An attacker who knows the key can forge a signed, encrypted cookie containing a crafted serialized PHP object. When the framework decrypts and unserializes it, that object can trigger a property-oriented gadget chain in your dependencies and lead to code execution. This is why a leaked key is treated as a critical incident rather than a routine secret rotation.

### How do I know if my APP_KEY has leaked?

Check whether your .env file was ever committed to git, exposed over HTTP at a path like /.env, printed on a Laravel debug error page in production, or included in an unencrypted backup or log. Search your git history with git log -p for the APP_KEY string, and confirm your production .env is not reachable from the internet. An external scanner such as StackShield probes for an exposed .env and leaking debug pages automatically, so you find the exposure before an attacker does.

