Rotating Your Laravel `APP\_KEY` Without Breaking Everything | Ghostable                            Article

 [ Blog ](https://ghostable.dev/blog)

 [ Articles ](https://ghostable.dev/blog/articles)

 [ Best Practices ](https://ghostable.dev/blog/category/best-practices)

   Wednesday, September 24, 2025

 Rotating Your Laravel APP\_KEY Without Breaking Everything
============================================================

  Learn how to rotate your Laravel APP\_KEY safely. Covers manual rotation, key rings with old keys, side effects, and when to drop old keys.

   ![Rotating Your Laravel APP_KEY Without Breaking Everything](https://fls-9fca3102-944c-48ac-a3cc-22f1b47a39c7.laravel.cloud/blog/01997bda-362d-732e-bfe6-8e525f3ade6f/app-key-rotation-without-breaking-everything.jpg) Laravel’s [APP\_KEY](https://laravel.com/docs/configuration#application-key) isn’t just another config value—it’s the [root cryptographic key](https://ghostable.dev/blog/laravel-app-key-vulnerability) for your app. It encrypts sessions and cookies, and it signs URLs generated by your app.

If that key leaks, an attacker can forge cookies, hijack sessions, or decrypt sensitive blobs. Unfortunately, many teams generate it once and [never touch it again](https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html). That’s not sustainable, and it’s a recipe for [hidden risk](https://ghostable.dev/learn/laravel-multi-environment-secrets).

The Manual (Big-Bang) Rotation
------------------------------

The simplest way to rotate your key is the “big-bang” approach: you replace the key and restart everything.

### How it works:

1. Generate a new key using Artisan

```bash
php artisan key:generate --show
```

2. Replace the .env `APP_KEY` value with the new base64 key.
3. [Clear and rebuild config cache](https://laravel.com/docs/configuration#configuration-caching)

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

4. [Gracefully reload workers](https://laravel.com/docs/queues#restarting-workers)

```bash
# Restart classic queue workers
php artisan queue:restart

These breakages are exactly why most teams eventually move to a [production-safe rotation strategy](https://ghostable.dev/blog/laravel-app-key-rotation-playbooks) instead of one-shot key changes.

# Restart Horizon supervisors (graceful)
php artisan horizon:terminate

# (Optional) pause/continue around Horizon restart for zero-downtime
php artisan horizon:pause
php artisan horizon:terminate
php artisan horizon:continue
```

### What breaks:

- All users are logged out.
- “Remember me” cookies stop working.
- Signed URLs instantly fail.
- Any encrypted DB fields become unreadable.

This approach is fine for small apps or emergencies, but it’s disruptive in production.

The Safer Way: Key Ring with Old Keys
-------------------------------------

A better solution is to use a [key ring](https://ghostable.dev/learn/env-naming-conventions): a [list of keys](https://12factor.net/config) where the newest encrypts data and older ones are still accepted for decryption.

In practice, you place the current key in `APP_KEY` and keep any older keys in a separate variable, such as `APP_KEYS_OLD`.

```bash
# Current key (always encrypts/signs)
APP_KEY=base64:NEW_CURRENT

# Comma-separated history of old keys (decrypt-only)
APP_KEYS_OLD="base64:OLD_1,base64:OLD_2"
```

**KeyRingEncrypter**

```php
use Illuminate\Contracts\Encryption\Encrypter as Contract;
use Illuminate\Encryption\Encrypter;

class KeyRingEncrypter implements Contract
{
    public function __construct(private array $keys, private string $cipher) {}

    public function encrypt($value, $serialize = true)
    {
        return (new Encrypter($this->keys[0], $this->cipher))->encrypt($value, $serialize);
    }

    public function decrypt($payload, $unserialize = true)
    {
        foreach ($this->keys as $key) {
            try {
                return (new Encrypter($key, $this->cipher))->decrypt($payload, $unserialize);
            } catch (\Throwable) {
                // try next key
            }
        }
        throw new \RuntimeException('Decryption failed with all keys.');
    }

    public function encryptString($value) { return $this->encrypt($value, false); }
    public function decryptString($payload) { return $this->decrypt($payload, false); }
}
```

This custom encrypter wraps Laravel’s built-in encrypter but accepts an array of keys. It always encrypts with the first (current) key, and tries each key in order when decrypting. This makes old values still readable while new values get written with the latest key. This approach becomes especially valuable in [multi-environment setups](https://ghostable.dev/learn/laravel-multi-environment-secrets) where keys must be rotated without desynchronizing production, staging, and CI.

**AppServiceProvider**

```php
use Illuminate\Support\Str;
use App\Support\KeyRingEncrypter;

public function register(): void
{
    $this->app->singleton('encrypter', function () {
        $cipher = config('app.cipher', 'AES-256-CBC');

        $encoded = array_filter([
            env('APP_KEY'),
            ...array_map('trim', explode(',', (string) env('APP_KEYS_OLD', ''))),
        ]);

        $keys = array_map(fn ($k) =>
            Str::startsWith($k, 'base64:') ? base64_decode(substr($k, 7)) : $k,
            $encoded
        );

        return new KeyRingEncrypter($keys, $cipher);
    });
}
```

This replaces Laravel’s default encrypter binding in the container. It loads both the current and old keys from your .env, decodes them, and passes them to the KeyRingEncrypter. With this in place, every call to Crypt automatically benefits from key ring support.

**Usage in Code**

```php
use Illuminate\Support\Facades\Crypt;

// Encrypts with the NEW key
$encrypted = Crypt::encryptString('secret');

// Decrypts with NEW, or OLD if necessary
$plain = Crypt::decryptString($encrypted);
```

No changes are needed in your app code. You can keep using Crypt::encryptString and Crypt::decryptString as usual—encryption happens with the new key, and decryption works with both new and old keys transparently.

**Background Re-encrypt Job**

```php
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;

class ReencryptColumn
{
    public function handle(string $table, string $column, string $pk = 'id', int $chunk = 500): void
    {
        DB::table($table)->orderBy($pk)->whereNotNull($column)
            ->chunkById($chunk, function ($rows) use ($table, $column, $pk) {
                foreach ($rows as $row) {
                    try {
                        $plain = Crypt::decryptString($row->{$column}); // NEW or OLD
                        $fresh = Crypt::encryptString($plain);          // always NEW
                        if ($fresh !== $row->{$column}) {
                            DB::table($table)->where($pk, $row->{$pk})
                              ->update([$column => $fresh]);
                        }
                    } catch (\Throwable $e) {
                        report($e);
                    }
                }
            });
    }
}
```

This job is a safe way example to migrate encrypted database columns to the new key. It decrypts values (using any key in the ring) and writes them back encrypted with the newest key. Once this job is complete, you can safely remove the old key from `APP_KEYS_OLD`.

### With this setup:

- New encryptions always use `APP_KEY`.
- Decryption tries the current key first, then falls back to the old list.
- Over time, as sessions and encrypted values are refreshed, the old keys naturally phase out.

This means users stay logged in, cookies remain valid, and encrypted columns are still readable until you fully re-encrypt.

When to Drop Old Keys
---------------------

A key ring only works if you eventually prune it. Keeping old keys around forever just increases your attack surface. You’re ready to drop an old key when all of the following are true:

- **Sessions &amp; Cookies:** The maximum session TTL and remember-me TTL have passed.
- **Signed URLs:** All links created before rotation have expired (unless you built multi-key verification).
- **Encrypted DB Fields:** Background re-encrypt jobs have finished.
- **Monitoring:** No decryption errors appear in logs for at least a full cycle.

Once these conditions are met, remove the old key from APP\_KEYS\_OLD, redeploy, and destroy it.

👉 For advanced rotation playbooks—including how to respond to a breach and how to handle employee terminations—read [Part 2: Laravel APP\_KEY Rotation Playbooks](https://ghostable.dev/blog/laravel-app-key-rotation-playbooks).

 [   Back to blog ](https://ghostable.dev/blog) [All articles](https://ghostable.dev/blog/articles)

  Want product news and updates?
--------------------------------

 Sign up for our newsletter.

   Email Address

  Subscribe →    Subscribing...

We care about your data. Read our [privacy policy](https://ghostable.dev/privacy).
