---
title: "Key custody"
manual: "nr-vault"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:operations-key-custody@1.0"
source: "Operations/KeyCustody.rst"
rendered: "2026-09-18T07:37:50+00:00"
---

# Key custody {#key-custody}

Where the master key lives under each provider, who can read it, and how it is
rotated. The one question that decides everything on this page: **who else
runs as the PHP process user?** Anything readable by that user is readable by
the vault's attacker in a process compromise.

Design rationale: [ADR-003: Master key management](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-003-master-key-management@1.0). Request-lifetime
caching: [ADR-020: Master key request-lifetime caching](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-020-master-key-request-lifetime-caching@1.0).

## Provider comparison {#provider-comparison}

| Provider | Key at rest | Who can read it | Rotation | Hardened profile |
| --- | --- | --- | --- | --- |
| `typo3` | Nowhere — derived on demand from `SYS/encryptionKey` with HKDF-SHA256. | Anyone who can read `config/system/settings.php`, plus every backup of it. | Only by rotating TYPO3's `encryptionKey`, which orphans every stored secret unless the vault is rotated in the same operation. | **Rejected** (`1753900002`). |
| `file` | A file outside the web root, `0400`, path in `masterKeySource`. | The file's owner, and root. Not the database. | `vault:rotate-master-key` writes the new key in place. | Permitted. |
| `env` | An environment variable named by `masterKeySource` (default `NR_VAULT_MASTER_KEY`). | Anything that can read the process environment: the process itself, root, and whatever injected it. | Out of band — the provider cannot persist a value. Set the new variable, restart, then rotate. | Permitted. |
| `transit` | Only the **wrapped** ciphertext (`vault:v1:…`) locally, at `hashicorp.transitWrappedKeyPath`. Unwrapping is a live Vault call. | Anyone holding the Vault token *and* the wrapped blob. Neither alone suffices. | Two independent rotations — see [Transit key rotation is a different operation](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-key-rotation-transit@1.0). | Permitted; it is the kind of external custody the profile asks for. |

> [!NOTE]
> The `transit` provider ships in the same release train as this
> documentation. On a build without it, `masterKeyProvider` accepts
> `typo3`, `file` and `env`; an unknown value is refused with
> exception code `1703800015`.

> [!NOTE]
> `masterKeyProvider` also accepts the identifier of a provider another
> extension registers, and such a provider is permitted in the hardened
> profile — only `typo3` is refused there. See
> [Custom master key providers](https://docs.typo3.org/permalink/netresearch/nr-vault:developer-custom-key-providers@1.0).

## `typo3` — derived from the TYPO3 encryption key {#typo3-derived-from-the-typo3-encryption-key}

The default, and the only zero-configuration option. HKDF-SHA256 over
`SYS/encryptionKey` with the domain-separation string
`nr-vault-master-key`, yielding 32 bytes. Source keys shorter than 32
characters are refused outright — they would give HKDF far less than 256 bits
of entropy.

**What it does not do is separate the vault from TYPO3.** The derivation is
sound; the custody is not improved at all. See
[The typo3 provider does not separate the vault from TYPO3](https://docs.typo3.org/permalink/netresearch/nr-vault:security-known-limitations-typo3-provider@1.0).

Two operational consequences that catch people out:

-   `encryptionKey` is vault key material for as long as this provider is
    configured. Rotating it orphans every secret.
-   The provider defines no destructor, so its request-lifetime cache slot
    survives individual instances. Long-running processes — scheduler daemons,
    messenger workers — must call `Typo3MasterKeyProvider::clearCachedKey()`
    to observe a rotated `encryptionKey`.

Acceptable for: development, staging, and installations where the secrets are
no more sensitive than the rest of `settings.php`. Not acceptable for a
hardened deployment, and the profile enforces that.

## `file` — a key file outside the web root {#file-a-key-file-outside-the-web-root}

**Extension configuration**

```none
masterKeyProvider = file
masterKeySource = /var/lib/typo3-secrets/vault-master.key
```

The path must be outside the document root. The file is written with the umask
tightened to `0o077` *before* the write and then `chmod` to `0400`, so
there is no window in which it is world-readable — an ordering that matters on
hosts with permissive umasks.

**Recommended ownership**

```bash
# Owned by the user the PHP process runs as; nothing else needs it.
install -d -m 0700 -o www-data -g www-data /var/lib/typo3-secrets
chown www-data:www-data /var/lib/typo3-secrets/vault-master.key
chmod 0400 /var/lib/typo3-secrets/vault-master.key
```

The provider accepts the file as raw 32 bytes or as base64 (trailing newlines
are tolerated). Anything else is rejected on length rather than padded.

> [!WARNING]
> If `masterKeySource` is empty or left at the default, the provider falls
> back to an auto-generated development key under
> `Environment::getVarPath() . '/secrets/vault-master.key'`. That
> location is convenient for development and wrong for production: it lives
> inside the TYPO3 var path, which many deployment and backup routines treat
> as ordinary application state. Always set `masterKeySource` explicitly.

**Checklist:** outside the web root; `0400`; owned by the PHP user; excluded
from application backups and included in a *separate* key backup
([Backup and restore](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-backup-and-restore@1.0)); on a filesystem whose snapshots you
also control; access-logged if the platform allows it.

## `env` — an environment variable {#env-an-environment-variable}

**Extension configuration**

```none
masterKeyProvider = env
masterKeySource = NR_VAULT_MASTER_KEY
```

The natural fit for containers and any platform with a secret-injection
mechanism (Kubernetes secrets, systemd `LoadCredential`, a supervisor's
environment file). Raw 32 bytes or base64; the provider zeroes the base64
string once decoded.

**Where environment variables leak.** Process listings on some platforms,
`phpinfo()`, crash dumps, `/proc/<pid>/environ` for the same user or root,
child processes, and — commonly — CI logs and container inspection output.
Verify that yours does not, rather than assuming.

The provider cannot persist a value, so `vault:rotate-master-key` cannot
write the new key for you: set the variable in the injection mechanism,
restart the process, and rotate with the new value supplied explicitly.

## `transit` — wrapped by HashiCorp Vault {#transit-wrapped-by-hashicorp-vault}

**Extension configuration**

```none
masterKeyProvider = transit
hashicorp.address = https://vault.example.internal:8200
hashicorp.authMethod = token
hashicorp.tokenEnvVar = VAULT_TOKEN
hashicorp.transitMount = transit
hashicorp.transitKeyName = nr-vault-master
hashicorp.transitWrappedKeyPath = /var/lib/typo3-secrets/vault-master.wrapped
```

The master key is generated once, wrapped by Vault's transit engine, and only
the ciphertext is stored locally. Every unwrap is a `POST` to
`/v1/{mount}/decrypt/{key}`.

**Custody notes.**

-   **Token, not configuration.** The token is read from
    `hashicorp.tokenEnvVar` in preference to `hashicorp.token`, because a
    token in the extension configuration is readable in the Install Tool and
    lands in configuration exports. Leave `hashicorp.token` empty in
    production.
-   **Token auth only.** `approle` and `kubernetes` are rejected rather
    than silently downgraded to something weaker.
-   **Least-privilege policy.** The token needs `update` on
    `transit/decrypt/<key>`, plus `update` on `transit/encrypt/<key>`
    only for initialisation and rotation. Nothing else. Withhold
    `transit/keys/*` so the vault cannot delete or export its own key.
-   **Path safety is enforced.** Mount segments and the key name must match
    `[A-Za-z0-9._-]+` and must not be `.` or `..`, so a configured mount
    cannot traverse the API path. A nested mount such as `platform/transit`
    stays usable.
-   **The wrapped file is written safely.** `0600` (rotation must overwrite
    it) via write-to-temp-then-rename, because a half-written wrapped key is
    an unrecoverable vault rather than a failed write.
-   **Errors are redacted.** Token-shaped strings are stripped from transport
    error messages, and non-2xx response bodies are never surfaced — Vault
    echoes the submitted ciphertext on some error paths.

**What it buys and what it does not.** Custody, rotation and central,
revocable audit of every unwrap; a stolen database plus a stolen webroot is
useless. It does **not** protect a live attacker inside the request — see
[A KMS protects custody, not runtime](https://docs.typo3.org/permalink/netresearch/nr-vault:security-known-limitations-kms@1.0). And it makes Vault an availability
dependency: `isAvailable()` performs no network call so an outage does not
become a per-request timeout, but the first real key load will fail.

## HSM and cloud KMS {#hsm-and-cloud-kms}

nr-vault has no direct HSM or cloud-KMS integration. The `transit` provider
is the supported indirection: HashiCorp Vault can itself be backed by an HSM
or a cloud KMS for its own seal, which puts the vault's master key under that
custody transitively without nr-vault needing a provider per KMS vendor.

Anything else — AWS KMS, Azure Key Vault, GCP KMS directly — needs a
`MasterKeyProviderInterface` implementation, and an extension may supply
one: tag it `nr_vault.master_key_provider` and set `masterKeyProvider` to
the identifier it returns from `getIdentifier()`.
`AbstractMasterKeyProvider` already implements the caching and wiping
contract, so the surface is small; the registration is documented in
[Custom master key providers](https://docs.typo3.org/permalink/netresearch/nr-vault:developer-custom-key-providers@1.0).

No such provider ships here. The custody of one is the custody its author gave
it, and nothing on this page describes it — the comparison table above covers
the four providers this package ships and no others.

## What custody cannot fix {#what-custody-cannot-fix}

Regardless of provider, the master key is present in the PHP process for the
duration of a request that touches a secret. It is cached in a static keyed by
provider class, and wiped with `sodium_memzero()` when the slot is
cleared — but while it is there, code running in that process can read it.

Custody decides who can obtain the key *outside* the request. It has no
opinion about the request itself. Everything on this page is about the former.
