Skip to content

KMS Backends

Edit this page

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 (12 by 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 mode option to the vendor value documented by your server, a hexadecimal string between 0x80000000 and 0x8FFFFFFF.

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.

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version