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.
php 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
// 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.
$ 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.
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.phpGetting 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.
# 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