The Secrets Vault

The secrets vault keeps encrypted values in the repository and the decryption key out of it, exposed to config as env vars.

Syntaxphp bin/console secrets:set <NAME> [--local|--random|-]

Environment variables solve where a credential lives, but not how it travels. .env.local is uncommitted, so a new machine starts with no values and someone pastes them into a chat. The secrets vault fixes that: the encrypted values are committed with the code, and only the key to read them is handled out of band.

How it works

php bin/console secrets:generate-keys writes a key pair to config/secrets/<env>/: <env>.encrypt.public.php, which encrypts and is safe to commit, and <env>.decrypt.private.php, which reads. php bin/console secrets:set MAILER_DSN prompts for a value, encrypts it with the public key and writes one file into the vault. Anyone can add a secret with the public key alone.

Secrets reach your configuration as environment variables, so %env(MAILER_DSN)% works whether the value came from the vault or the shell. A real environment variable of the same name wins over the vault, which is how one developer overrides a value locally; secrets:set NAME --local is the short way to write that override into .env.<env>.local instead of editing the file by hand. The vault needs PHP's sodium extension, which is bundled with PHP 8.5.

The key in production

Three options: copy prod.decrypt.private.php to the server; set SYMFONY_DECRYPTION_SECRET to the base64-encoded key; or run secrets:decrypt-to-local --force during the deploy, which writes .env.prod.local so the key never stays on the machine. A managed secret store is the fourth option, and arrives as environment variables too.

Example

Example · php
// src/Service/WebhookSignatureVerifier.php
declare(strict_types=1);

namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class WebhookSignatureVerifier
{
    public function __construct(
        // Stored with: php bin/console secrets:set WEBHOOK_SIGNING_KEY
        // Secrets are exposed as environment variables, so this class does not
        // know, and does not need to know, that the value was encrypted.
        #[Autowire(env: 'WEBHOOK_SIGNING_KEY')]
        private string $signingKey,
    ) {
    }

    public function isValid(string $payload, string $signature): bool
    {
        $expected = hash_hmac('sha256', $payload, $this->signingKey);

        // hash_equals compares in constant time; === leaks timing information
        return hash_equals($expected, $signature);
    }
}

// $verifier = new WebhookSignatureVerifier('test-key');
// $body = '{"id":7}';
// var_dump($verifier->isValid($body, hash_hmac('sha256', $body, 'test-key')));
// Output: bool(true)

When to use it

  • Give a new developer a working checkout without emailing them any credential.
  • Rotate a webhook signing key in a reviewable commit.
  • Keep production credentials out of the repository while still versioning which ones exist.

More examples

Creating and inspecting the vault

The whole working loop. secrets:list without --reveal shows names only and needs no private key, which makes it safe in CI; with --reveal it decrypts, so it fails on a machine that has only the public key. The Local Value column is where a local override would show.

Example · bash
$ php bin/console secrets:generate-keys

# value is prompted for and not echoed
$ php bin/console secrets:set WEBHOOK_SIGNING_KEY

# or from a pipe, so it never reaches your shell history
$ echo -n "$KEY" | php bin/console secrets:set WEBHOOK_SIGNING_KEY -

# or let Symfony generate one
$ php bin/console secrets:set REMEMBER_ME_SECRET --random

$ php bin/console secrets:list --reveal

 ---------------------- -------------- -------------
  Name                   Value          Local Value
 ---------------------- -------------- -------------
  WEBHOOK_SIGNING_KEY    "wh_live_..."
  REMEMBER_ME_SECRET     "8c2f..."
 ---------------------- -------------- -------------

Where the vault lives, and what git must ignore

One file per secret, so two people adding different secrets do not conflict. Add the prod private key to .gitignore before you generate it, not after, because a key that reached one commit has to be treated as leaked and rotated.

Example · bash
config/secrets/
  dev/
    dev.encrypt.public.php      # commit
    dev.decrypt.private.php     # commit (dev values are not real credentials)
    dev.WEBHOOK_SIGNING_KEY.bbef53.php
  prod/
    prod.encrypt.public.php     # commit
    prod.decrypt.private.php    # NEVER commit
    prod.WEBHOOK_SIGNING_KEY.bbef53.php

# .gitignore
/.env.local
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php

Getting the values onto a server

Option 1 keeps one secret to manage instead of many, which suits a platform that injects environment variables. Option 2 suits an image build: the decrypted file ships inside the artifact and the key stays in the build system.

Example · bash
# option 1: the key as an environment variable
$ php -r 'echo base64_encode(require "config/secrets/prod/prod.decrypt.private.php");'
# set the output as SYMFONY_DECRYPTION_SECRET on the server

# option 2: decrypt during the deploy, then drop the key
$ APP_RUNTIME_ENV=prod php bin/console secrets:decrypt-to-local --force
# writes .env.prod.local with plain values; the key is no longer needed at runtime

Discussion

  • Be the first to comment on this lesson.