KMS Backends
Each backend is reached through the DSN of a client, as
Configuring the KMS Clients explains. This page documents what each one
takes and what sets it apart. The classes are shown too, since a client
wired by hand and declared with a service:// DSN builds them itself.
Warning
The KeyManagement component is experimental and is not covered by Symfony's Backward Compatibility Promise.
Local Backends
Three backends keep the key material where the application can reach it, which is what makes them suitable for development, tests and self-hosted setups, and what sets them apart from a real KMS.
libsodium
SodiumKms uses XChaCha20-Poly1305-IETF with a random 24-byte nonce. Every key the loader returns must be exactly 32 bytes:
1 2
$key = sodium_crypto_aead_xchacha20poly1305_ietf_keygen();
file_put_contents('/etc/kms/app-key.bin', $key);
This is the recommended local backend when ext-sodium is available. It
ships with PHP but is sometimes disabled at compile time.
OpenSSL
OpenSslKms uses
AES-256-GCM with a random 12-byte nonce, and the same 32-byte keys. Use it
when ext-sodium is unavailable, or to share keys with systems that only
speak AES.
Because its nonces are random, NIST SP 800-38D limits a key to 2^32 encryptions, the data keys it wraps included. Prefer libsodium for a key that encrypts more values than that.
Sealed Box
SealedBoxKms uses
anonymous public-key encryption (sodium_crypto_box_seal: Curve25519
with XSalsa20-Poly1305). It gives a workflow close to
Symfony's secrets vault in a self-hosted
setup:
- a deployment loading only the public key can encrypt new payloads, so developers can commit encrypted blobs, but can decrypt nothing;
- a deployment loading the full keypair, secret and public in the 64-byte libsodium convention, can decrypt at runtime.
Generate a keypair with:
1 2
$keypair = sodium_crypto_box_keypair(); // 64 bytes
$publicKey = sodium_crypto_box_publickey($keypair); // 32 bytes
A sealed box takes no authenticated data and doesn't authenticate its sender, so this backend doesn't support AAD, and anyone holding the public key can create a ciphertext that the backend decrypts.
Key Loaders
The three local backends read their key material through a KeyLoaderInterface. Two implementations ship:
- InMemoryKeyLoader
-
An
array<string, string>indexed by key id. For tests, and for keys passed through environment variables. - FilesystemKeyLoader
-
Reads each key from the file named after its key id in a local
directory, subdirectories included, with an optional extension
(
.bin,.key, ...). The file holds the raw key, with no encoding and no trailing newline.
To read keys from S3, FTP, Azure Blob or anything else Flysystem supports, see KMS Backends.
DSN Options
The inline schemes take the keys as keys[<id>]=<base64> query options:
1
sodium://?keys[app-key]=BASE64_ENCODED_32_BYTES&keys[other]=BASE64_ENCODED_32_BYTES
Written in the URL-safe base64 of RFC 4648 section 5, with or without
padding, a key needs no urlencode() step. Standard base64 is accepted
too, as long as each + is written %2B: a query string reads a +
as a space, so a key containing a raw + is refused. That permissive decoding is available
on its own as Base64UrlSafe.
The directory schemes take an optional ext option restricting the
loader to one file extension:
1
sodium+dir:///etc/kms?ext=.bin
AWS KMS
Backed by the async-aws/kms client. Encryption, decryption and data key creation all happen inside AWS; the master key never reaches the application:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
use AsyncAws\Core\Configuration;
use AsyncAws\Core\HttpClient\AwsHttpClientFactory;
use AsyncAws\Kms\KmsClient;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\KeyManagement\Bridge\AwsKms\AwsKms;
$kms = new AwsKms(new KmsClient(
Configuration::create(['region' => 'eu-west-1']),
null,
// a redirect would send the credentials and the plaintext again, and
// the retryable client retries the throttled calls
AwsHttpClientFactory::createRetryableClient(
HttpClient::create(['max_redirects' => 0])
),
));
// a key ARN, a key id or an alias
$ciphertext = $kms->encrypt('alias/app-key', 'hello world');
DSN scheme:
1
aws-kms://[<accessKey>:<secretKey>@]<host>[:<port>]?region=<region>[&session_token=<token>&scheme=http&timeout=<seconds>&max_duration=<seconds>]
The host default selects the public AWS endpoint for the region; any
other host is a custom endpoint, such as LocalStack or a VPC endpoint.
Leaving the credentials out lets async-aws walk its usual provider
chain: environment variables, instance profile and so on. The scheme
option applies to custom hosts only and defaults to https; pass
scheme=http for a local sandbox
(aws-kms://localhost:4566?region=eu-west-1&scheme=http). Combining it
with the default host is refused.
The client built from the DSN talks through the http_client service of
the application, which async-aws uses as is: throttled calls are
retried only when that client has a
retry strategy.
Every ciphertext records the key ARN that AWS reported rather than the id
it was given. An alias moved to a new key, which is how a rotation is
usually performed by hand, therefore leaves older ciphertexts readable:
AWS rejects a Decrypt naming a key other than the one that encrypted
the blob.
AWS exposes AAD as its EncryptionContext, an array<string, string>.
To keep the opaque-bytes contract of EncrypterInterface, this bridge
stores the AAD, base64-encoded, under the fixed aad key. Other tools
decrypting such a ciphertext must therefore pass the same encryption
context.
Azure Key Vault
Backed by the Azure Key Vault REST API, Managed HSM included:
1 2 3 4 5 6 7 8 9 10 11 12 13
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\KeyManagement\Bridge\AzureKeyVault\AzureKeyVault;
use Symfony\Component\KeyManagement\Bridge\AzureKeyVault\ClientCredentialsTokenProvider;
$client = HttpClient::createForBaseUri('https://my-vault.vault.azure.net/');
$tokens = new ClientCredentialsTokenProvider(
$client,
$_SERVER['AZURE_TENANT_ID'],
$_SERVER['AZURE_CLIENT_ID'],
$_SERVER['AZURE_CLIENT_SECRET'],
);
$kms = new AzureKeyVault($client, $tokens);
DSN scheme:
1
azure-keyvault://<clientId>:<clientSecret>@<vault-name>.vault.azure.net?tenant=<tenantId>[&algorithm=...&wrap_algorithm=...&api_version=...&audience=...&timeout=<seconds>&max_duration=<seconds>]
The host is the full vault DNS name; use <name>.managedhsm.azure.net
for Managed HSM. The audience is inferred from that suffix, and
audience=... overrides it. api_version defaults to 7.4. The
token is always requested from the public Microsoft Entra authority, so a
sovereign cloud needs an AzureKeyVault built by hand, with a
ClientCredentialsTokenProvider given the authority of that cloud as its
last argument, and declared with a service:// DSN.
Azure encrypts and wraps keys through separate operations, each with its own
permission. Direct calls go through encrypt and decrypt, under the
algorithm option. Data keys, including the ones that
key-management:rewrap-data-keys moves, go through wrapKey and
unwrapKey, under wrap_algorithm. Both options default to
RSA-OAEP-256. Only A128GCM, A192GCM and A256GCM, which only
Managed HSM offers, support AAD. An AEAD blob carries its algorithm
(ALG.iv.tag.value), so a bridge reconfigured from AEAD to another algorithm
still reads what it wrote. Any other blob is read with the configured
algorithm, so don't change that option while such blobs exist.
A key id is either <name> or <name>/<version>, and both segments
are validated against what Azure accepts, so a malformed id such as
../secrets is refused rather than normalized into another vault path.
Azure uses the latest version of a key when a request names none, so a rotation would break every ciphertext written before it. Every ciphertext and every wrapped data key therefore records a versioned key id: the version Azure reports for a versionless write, or the one the caller pinned. A response naming another key, or another version, fails the write.
Authentication uses the client_credentials grant by default. For
Managed Identity, Workload Identity or any other flow, implement
TokenProviderInterface
and pass it in. A provider is expected to cache its token, which is why the
interface also has invalidateToken(); see
KMS Backends.
Google Cloud KMS
Backed by the Google Cloud KMS REST API:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\KeyManagement\Bridge\GoogleCloudKms\GoogleCloudKms;
use Symfony\Component\KeyManagement\Bridge\GoogleCloudKms\ServiceAccountTokenProvider;
$client = HttpClient::createForBaseUri('https://cloudkms.googleapis.com/v1/');
$tokens = ServiceAccountTokenProvider::fromJsonFile(
$client,
'/path/to/service-account.json',
);
$kms = new GoogleCloudKms($client, $tokens);
// the key id is the full resource name; append
// /cryptoKeyVersions/<n> to pin a version
$ciphertext = $kms->encrypt(
'projects/my-project/locations/global/keyRings/app/cryptoKeys/master',
'hello world',
);
DSN scheme:
1
gcp-kms://default?credentials=/path/to/service-account.json[&timeout=<seconds>&max_duration=<seconds>]
The host default selects the public endpoint; any other host is a
custom one. credentials points at a service-account JSON key file.
Authentication uses the JWT-bearer flow by default: the bundled ServiceAccountTokenProvider signs an assertion with the service-account RSA key and exchanges it for an access token. For Application Default Credentials, the metadata server of GCE, GKE or Cloud Run, or Workload Identity Federation, implement TokenProviderInterface.
Cloud KMS has no GenerateDataKey primitive, so the bridge draws a data
key locally with random_bytes() and wraps it through the regular
encryption endpoint.
Refreshing a Rejected Token
A cached token that is revoked, or rejected for any other reason, would
keep failing every call until it expires. The Azure and Google bridges
therefore call invalidateToken() when the provider answers 401, ask
for a token again, and retry once if the new token differs. A 403 is
reported as it is: both vendors document it as a permission or firewall
denial, which another token does not change. The Vault bridge invalidates
and retries the same way, but on a 403 carrying an invalid token
error, which is how Vault rejects an expired or revoked token.
A provider that caches nothing leaves the method empty. One with a cache drops the entry only while it still holds the rejected token, so a token another caller has already refreshed survives:
1 2 3 4 5 6
public function invalidateToken(#[\SensitiveParameter] string $token): void
{
if ($token === $this->cachedToken) {
$this->cachedToken = null;
}
}
The method only evicts a cached value. It must neither revoke the token remotely nor log it, and a cache shared between processes does not need to compare and delete atomically: the worst case is one extra token request.
HashiCorp Vault Transit
Backed by the Vault Transit secret engine:
1 2 3 4 5 6 7 8 9 10 11
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\KeyManagement\Bridge\HashiCorpVault\StaticTokenProvider;
use Symfony\Component\KeyManagement\Bridge\HashiCorpVault\TransitKms;
$kms = new TransitKms(
HttpClient::createForBaseUri('https://vault.example.com:8200/v1/'),
new StaticTokenProvider($_SERVER['VAULT_TOKEN']),
);
$ciphertext = $kms->encrypt('app-key', 'hello world');
// the blob looks like "vault:v1:..."
DSN scheme:
1
hashicorp-vault-transit://<token>@<host>[:<port>][/<path>][?mount=<mount>&namespace=<ns>&scheme=http&timeout=<seconds>&max_duration=<seconds>]
The path is the prefix of the Vault API, /v1/ by default, and the mount
point defaults to transit. namespace maps to Vault's
X-Vault-Namespace header, for Vault Enterprise. scheme defaults to
https; pass scheme=http for a local dev server.
TransitKms gets its token from a
TokenProviderInterface,
which has a getToken() and an invalidateToken() method. Two
implementations are bundled:
- StaticTokenProvider returns a token obtained out of band. The DSN token goes through it;
KubernetesAuthTokenProvider logs in to the Kubernetes auth method with the service-account token of the pod, so that an application doesn't need a long-lived Vault token or a Vault Agent sidecar:
1 2 3 4 5 6 7
use Symfony\Component\KeyManagement\Bridge\HashiCorpVault\KubernetesAuthTokenProvider; $client = HttpClient::createForBaseUri('https://vault.example.com:8200/v1/'); $kms = new TransitKms( $client, new KubernetesAuthTokenProvider($client, 'my-app'), );
The second argument of KubernetesAuthTokenProvider is the Vault role.
The next ones are the path the auth method is enabled at (kubernetes by
default, without the auth/ prefix), the path of the service-account
token (/var/run/secrets/kubernetes.io/serviceaccount/token by default)
and the Vault Enterprise namespace. The service-account token is read at
each login, since the kubelet rotates it. The Vault token is cached in
memory until 60 seconds before its lease ends, or until half of the lease
when it is shorter than two minutes. A token without a lease stays cached
until it is invalidated.
The token is cached in the provider object, which PHP-FPM rebuilds for
every request, so each request that calls Vault logs in again and gets a
new Vault token, which Vault keeps until its lease ends.
Use a worker runtime such as FrankenPHP, or wrap the provider in one of
your own that caches the token in a store shared by all the processes. To
authenticate with another method, implement TokenProviderInterface.
In an application, a client built from a DSN always uses the token of the
DSN. To use a provider, declare the TransitKms as a service and
reference it with service://<id> in key_management.clients, as
Configuring the KMS Clients explains.
Since version 1.17, Vault answers an expired or revoked token with an HTTP
403 and an invalid token error. Only then does the bridge follow
the token refresh. Any other 403,
such as a policy lacking a capability, is reported as it is, since a new
token would not fix it. Older versions only answer permission denied,
so the request is not retried.
Every request carries a context, Vault's HKDF input. It is the AAD
prefixed with a fixed label of the bridge, so that every AAD, an empty one
included, gives a distinct and non-empty context, which is what a key
created with derived=true requires. Such a key is therefore usable when
no AAD is passed.
A non-empty AAD is refused on a plain symmetric key, because Vault
ignores a context on a non-derived key rather than rejecting it, which
would let a ciphertext bound to one tenant decrypt under another. The
bridge therefore reads the key configuration once per key whenever the
AAD is non-empty, and throws
UnsupportedOperationException
when the key cannot enforce it. A key created with derived=true is also
what a data key store needs to turn
bind_wrapping_context on.
That read is a capability of its own, so a least-privilege token limited to
the encryption endpoints stops working as soon as an AAD reaches the bridge.
That happens with direct calls passing an AAD and with
bind_wrapping_context on, but not with an envelope encrypter alone:
1 2 3 4 5
path "transit/encrypt/*" { capabilities = ["update"] }
path "transit/decrypt/*" { capabilities = ["update"] }
path "transit/datakey/plaintext/*" { capabilities = ["update"] }
# only needed with AAD, to tell derived keys from non-derived ones
path "transit/keys/*" { capabilities = ["read"] }
On a key created with convergent_encryption=true, equal plaintexts
encrypted with the same AAD give equal ciphertexts, so each AAD value is a
convergence scope of its own.
Scaleway Key Manager
Backed by the Scaleway Key Manager API. Encryption and decryption happen
inside Scaleway; the key never reaches the application. The client must be
scoped to the regional Key Manager URL and the bridge sends the secret key
of an IAM API key in the X-Auth-Token header:
1 2 3 4 5 6 7 8 9 10 11 12 13
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\KeyManagement\Bridge\Scaleway\ScalewayKms;
$kms = new ScalewayKms(
HttpClient::createForBaseUri(
'https://api.scaleway.com/key-manager/v1alpha1/regions/fr-par/',
),
$_SERVER['SCW_SECRET_KEY'],
);
// the key id is the UUID of a key whose usage is "symmetric_encryption"
$keyId = '11111111-2222-3333-4444-555555555555';
$ciphertext = $kms->encrypt($keyId, 'hello world');
DSN scheme:
1
scaleway-kms://<secret_key>@<host>[:<port>]?region=<region>[&timeout=<seconds>&max_duration=<seconds>]
The region option is required (fr-par, nl-ams, pl-waw and
so on). The host default selects https://api.scaleway.com; any other
host is used as a custom HTTPS endpoint. The secret key goes in the user
part of the DSN, usually through an environment variable:
1
scaleway-kms://%env(SCW_SECRET_KEY)%@default?region=fr-par
A DSN with a password is refused: Key Manager calls don't need the project
id, which the PROJECT_ID:SECRET_KEY form of the Scaleway Mailer DSN
carries. The API key needs the KeyManagerFullAccess permission set, or a
narrower one granting the encrypt and decrypt operations.
The timeout and max_duration options and the HTTP client work as for the
other providers, see DSN Schemes. Requests
never follow redirects, since the X-Auth-Token header would follow a
redirect to another host.
The $aad argument is sent as Scaleway's associated_data, which
AES-256-GCM authenticates: decrypting with another AAD fails. Scaleway has
a data key endpoint, but it only makes 32-byte keys, cannot bind associated
data and refuses asymmetric keys. The bridge therefore draws the data key
locally with random_bytes() and wraps it through the encryption
endpoint, with the AAD when there is one. The key can be 16 bytes or
longer, within the payload limit of the Scaleway key, on symmetric and
asymmetric keys.
On decryption, an HTTP 400 or 404 is reported as a
DecryptionFailedException,
so that an unknown key cannot be told from a tampered ciphertext.
Authentication, quota and server errors stay a RuntimeException, and so
do 412 errors (a disabled key, or an AAD on an asymmetric_encryption
key, for instance); their message includes Scaleway's help_message. An
unknown key on encryption is a KeyNotFoundException.
Scaleway encrypts at most 64 KiB per call, so use envelope encryption for larger payloads.
KMIP
Backed by any server speaking KMIP, the OASIS key management protocol, over TLS with client-certificate authentication. The server holds the master key and performs the encryption:
1 2 3 4 5 6 7 8 9 10
use Symfony\Component\KeyManagement\Bridge\Kmip\KmipKmsFactory;
use Symfony\Component\KeyManagement\Dsn;
$kms = (new KmipKmsFactory())->create(Dsn::fromString(
'kmip://kmip.example.com?cert=/run/secrets/client.crt'
.'&key=/run/secrets/client.key&version=2.0&ca=/run/secrets/ca.crt'
));
$keyId = /* the KMIP Unique Identifier of an active AES key */;
$ciphertext = $kms->encrypt($keyId, 'hello world');
DSN scheme:
1
kmip://[<user>:<password>@]<host>[:<port>]?cert=<path>&key=<path>&version=<version>[&ca=<path>][&peer_name=<name>][&passphrase=<passphrase>][&cipher=aes-gcm][&mode=<vendor-mode>][&iv_length=12][&timeout=10]
cert, key and version are required: the client certificate and
its private key, and the KMIP protocol version to speak (1.4, 2.0 or
2.1). The bridge only performs authenticated Encrypt and Decrypt
operations, whichever version you select. The client certificate is required
even when the DSN includes a username and password. The port defaults to
5696.
The bridge always verifies the server certificate; no option turns that off.
ca is the certificate authority file to verify it against (the system
trust store by default) and peer_name is the expected server name (the
DSN host by default). passphrase unlocks an encrypted private key and
timeout is the time in seconds an operation may take, from the connection
to the response (10 by default).
The cipher option selects the algorithm the server encrypts with, which
determines the key to provision on the server:
aes-gcm(default)-
An active AES key. Only this cipher accepts
iv_length, the length in bytes of the IV used for new ciphertexts (12by default). chacha20-poly1305- An active 256-bit ChaCha20Poly1305 key, on a server that supports this algorithm.
aes-gcm-siv-
An active 128-bit or 256-bit AES key. KMIP defines no Block Cipher Mode
for AES-GCM-SIV, so you must also set the
modeoption to the vendor value documented by your server, a hexadecimal string between0x80000000and0x8FFFFFFF.
In all cases, the key needs Encrypt and Decrypt in its Cryptographic Usage Mask, and its Unique Identifier is the key id. All three ciphers produce a 16-byte authentication tag, and ChaCha20-Poly1305 and AES-GCM-SIV always use a 12-byte nonce.
This DSN selects AES-GCM-SIV on a server that documents 0x80000002 as its
mode (other servers can use a different value):
1
kmip://kmip.example.com?...&cipher=aes-gcm-siv&mode=0x80000002
Each ciphertext records the cipher, the mode and the IV length used to produce
it. Changing cipher or iv_length therefore only applies to new
ciphertexts, and earlier ones stay readable as long as the server and the key
still support them. Decryption only accepts the vendor mode set in mode,
though: keep that option in the DSN, even after changing cipher, as long
as you store AES-GCM-SIV ciphertexts.
The bridge draws a random nonce for each encryption. With AES-GCM, NIST
SP 800-38D therefore limits a key to 2^32 encryptions, even with a longer
iv_length, across all the applications that use the key. ChaCha20-Poly1305
also uses random 12-byte nonces, so apply the same limit to it. AES-GCM-SIV
resists nonce reuse: a repeated nonce only reveals whether the same plaintext
was encrypted twice with the same associated data.
A whole KMIP message must fit in 16 MiB, headers included. Direct encryption sends the plaintext to the server, so it is capped well below that. Envelope encryption with EnvelopeEncrypter is not, since only the data key crosses the network. Each operation opens its own TLS connection, which a data key store makes much less frequent.
Some servers report a bad authentication tag as a generic failure. PyKMIP,
for instance, answers General Failure, so the bridge throws a
RuntimeException
instead of a
DecryptionFailedException.
Flysystem
Provides a
FlysystemKeyLoader
that lets the local backends read their key material through any
league/flysystem adapter: S3, FTP, SFTP, Azure Blob, Google Cloud
Storage and so on. It also registers three DSN schemes,
sodium+fly://, openssl+fly:// and sodium-sealed-box+fly://,
which KeyManagementBundle picks up automatically.
The host of these DSNs names a Flysystem storage, and their path is the
directory of the keys inside it. The storages of league/flysystem-bundle
are found by their name. A Flysystem FilesystemReader service of your own
needs the key_management.flysystem tag, with a key attribute set to
that name.
Warning
An object store is a place to keep key material, not a key manager. Even behind ACLs, audit logs and server-side access control, the application still fetches the key material over the network and holds it in memory, which a real KMS never allows: AWS KMS, Azure Key Vault, Google Cloud KMS and Vault Transit perform the cryptographic operations themselves and never hand out the master key. Use this bridge for self-hosted and development setups, not as a replacement for one of those providers.
The loader keeps each key in memory once it has read it, so a remote store
is read once per key rather than once per operation. A failed read is not
kept, so the next lookup tries again. reset() drops everything. To do
that between requests, tag a loader you register as a service with
kernel.reset and a method attribute set to reset; a client built
from a DSN asks for the same with reset=1:
1
sodium+fly://keys.storage/keys?ext=.key&reset=1
Without that option the keys stay in memory across requests. The loader cannot be serialized, and a clone of one starts empty while the original keeps what it read.