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

Laravel’s APP_KEY isn’t just another config value—it’s the root cryptographic key 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. That’s not sustainable, and it’s a recipe for hidden risk.

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
php artisan key:generate --show
  1. Replace the .env APP_KEY value with the new base64 key.
  2. Clear and rebuild config cache
php artisan config:clear
php artisan config:cache
  1. Gracefully reload workers
# Restart classic queue workers
php artisan queue:restart

# 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: a list of keys 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.

# 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

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.

AppServiceProvider

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

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

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

Want product news and updates?

Sign up for our newsletter.

Email Address

We care about your data. Read our privacy policy.