DEV Community

Cover image for Why an encrypted config backup breaks when you move servers — and how I fixed it in laravel-config-backup
Nasrul Hazim Bin Mohamad
Nasrul Hazim Bin Mohamad

Posted on

Why an encrypted config backup breaks when you move servers — and how I fixed it in laravel-config-backup

Imagine you write a letter in a secret code that only your old house key can read. Then you move. You photocopy the coded letter, carry it to the new house… and realise the new key can't decode any of it. The letter is valid, just useless.

That's effectively what happens when you back up encrypted values from a Laravel database and restore them onto a different server. I hit exactly this while working on laravel-config-backup today, so here's the problem and the fix.

The real cause: Crypt is bound to APP_KEY

When you store sensitive settings (think API tokens or OAuth secrets) in the database, you typically encrypt them with Crypt::encryptString(). Lovely — until you remember Crypt uses your app's APP_KEY as the key.

A naive backup copies that ciphertext straight across:

// Naive approach — move the ciphertext as-is
$value = DB::table('settings')->where('key', 'some.secret')->value('value');
// this value is encrypted with the OLD server's APP_KEY
Enter fullscreen mode Exit fullscreen mode

The new server has a different APP_KEY. Try to decrypt → DecryptException: The payload is invalid. Your backup is technically complete but practically dead.

The fix: decrypt on the way out, re-encrypt on the way in

The decision is easy to state, hard to stay disciplined about: never carry ciphertext across a server boundary. Instead —

  1. On create: decrypt the values with the source server's APP_KEY, store plaintext inside the archive.
  2. Protect that archive with AES-256 and a password (a human-held secret, not the APP_KEY).
  3. On restore: re-encrypt the values with the destination server's APP_KEY before writing to the DB.

Back to the analogy: you decode the letter, carry the plain letter in a locked briefcase (the password-protected archive), and re-encode it with the new house's lock on arrival. The briefcase handles security in transit — not the old code that's no longer relevant.

I made that intent explicit right where the behaviour lives, in ConfigBackupService:

/**
 * Config Backup & Restore.
 *
 * Bundles .env + DB-stored settings into a single AES-256, password-encrypted
 * ZIP. Content inside the archive is stored DECRYPTED so the encrypted DB
 * columns are re-encrypted on import with the destination server's APP_KEY —
 * making a backup portable across servers.
 */
class ConfigBackupService { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

"Naked" plaintext inside the archive sounds scary, but the security boundary has moved on purpose: from the APP_KEY (which you want to differ per server) to the archive password (which you control and can rotate). That's the right trade-off for an artifact whose whole job is to move.

Authz: one source of truth, not scattered checks

The same pass hardened authorization. It's too easy to scatter gate checks across the UI, routes, and commands. One method everything refers to keeps it honest:

/**
 * Whether the current context passes the configured authorization gate.
 * Returns true when no gate is configured. CLI commands run by a server
 * operator deliberately bypass this. Single source of truth for authz.
 */
public function authorizes(): bool
{
    $gate = $this->gate();

    return $gate === null || Gate::allows($gate);
}
Enter fullscreen mode Exit fullscreen mode

Two subtle but important points:

  • The gate is nullable. If the host app doesn't set a gate, the package doesn't impose its own policy — you can rely on route middleware. Good tooling suggests, it doesn't dictate.
  • The CLI deliberately bypasses it. Someone running php artisan config-backup:create on the server already has shell access. Forcing them through a web gate is theatre, not security.

Keep it honest with a test

The portability part is hard to verify "by eye". I keep it honest with a round-trip test: encrypt with one key, simulate a different key, and assert the restore can still read the original value.

it('restores secrets under a different APP_KEY', function () {
    config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]);
    DB::table('settings')->insert([
        'key' => 'some.secret',
        'value' => Crypt::encryptString('super-secret'),
    ]);

    $backup = app(ConfigBackupService::class)->create(password: 'pa55');

    // Simulate the destination server: a different APP_KEY
    config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]);
    DB::table('settings')->truncate();

    app(ConfigBackupService::class)->restore($backup, password: 'pa55');

    $value = DB::table('settings')->where('key', 'some.secret')->value('value');
    expect(Crypt::decryptString($value))->toBe('super-secret');
});
Enter fullscreen mode Exit fullscreen mode

If this stays green across two different APP_KEYs, you know your backup is genuinely portable — not just "works on my machine".

The takeaway

Whenever you design something that crosses a boundary — server, environment, tenant — ask: which key is glued to this artifact, and does that key exist on the other side? The answer is often no, and the safest thing to carry is plaintext in a container whose key you control — not ciphertext bound to a key you left behind.

The package is open source: github.com/cleaniquecoders/laravel-config-backup.

Top comments (0)