---
title: "Hardened deployment"
manual: "nr-vault"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:operations-hardened-deployment@1.0"
source: "Operations/HardenedDeployment.rst"
rendered: "2026-09-18T07:37:50+00:00"
---

# Hardened deployment {#hardened-deployment}

A step-by-step deployment of the hardened profile, in an order that never
leaves the installation in a state where secrets are unreadable or every
administrator is locked out.

Read [Security profiles](https://docs.typo3.org/permalink/netresearch/nr-vault:security-profiles@1.0) first for what the profile changes, and
[Known limitations](https://docs.typo3.org/permalink/netresearch/nr-vault:security-known-limitations@1.0) for what it does not. If you are converting
an existing standard installation rather than deploying a new one, follow
[Migrating from standard to hardened](https://docs.typo3.org/permalink/netresearch/nr-vault:security-profiles-migration@1.0) — the sequence there rotates the key
*before* switching the profile, which is the part that matters.

> [!NOTE]
> Do the whole sequence on staging first. Step 6 withdraws the administrator
> override, and the recovery path for a mistake is a break-glass window —
> which is fine, but you want to have exercised it deliberately rather than
> for the first time under pressure.

## Step 1 — Choose a master-key provider {#step-1-choose-a-master-key-provider}

The hardened profile **rejects the \`\`typo3\`\` provider** with exception code
`1753900002`, and there is no auto-detection fallback that could quietly put
you back on it. Pick one of the others; the trade-offs are in
[Key custody](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-key-custody@1.0).

| Provider | Choose it when |
| --- | --- |
| `file` | Traditional server deployment with configuration management. The key is a file outside the web root at `0400`, owned by the PHP user. |
| `env` | Containers, or any platform with a secret-injection mechanism. Check first that your platform does not leak the environment into logs or inspection output. |
| `transit` | You already run HashiCorp Vault. Best custody story: only the wrapped ciphertext is stored locally and every unwrap is centrally audited and revocable. |
| an extension's own | Your key lives somewhere none of the above reaches — a cloud KMS, an HSM bridge. Permitted here: the profile refuses `typo3` by name, not everything it does not ship. Registration and the contract such a provider must hold to are in [Custom master key providers](https://docs.typo3.org/permalink/netresearch/nr-vault:developer-custom-key-providers@1.0). |

**Extension configuration — file provider**

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

Initialise the key if this is a new vault:

```bash
vendor/bin/typo3 vault:init
```

> [!WARNING]
> On an existing vault, do **not** run `vault:init` to move providers. Use
> `vault:rotate-master-key` ([Key rotation](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-key-rotation@1.0)).
> `vault:init` generates a fresh key, and a fresh key over an existing
> vault makes every secret unreadable — its own `--force` warning says so.

Back up the new key material **now**, separately from the database, and verify
the backup: [Backup and restore](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-backup-and-restore@1.0).

## Step 2 — Set the profile {#step-2-set-the-profile}

**Extension configuration**

```none
securityProfile = hardened
```

From this point the vault is fail-closed: no provider auto-detection, no
fallback to the TYPO3 encryption key, and a misconfigured provider stops vault
operations instead of continuing on weaker key material. An unknown value for
the setting is refused outright with code `1753900001`.

Verify immediately, before going further:

```bash
vendor/bin/typo3 vault:list
vendor/bin/typo3 vault:doctor --profile=hardened
```

A `vault:list` that works only proves the database is reachable — it reads
metadata and never touches the master key. The real check comes in step 7.

## Step 3 — Grant the operation permissions {#step-3-grant-the-operation-permissions}

Do this **before** step 6. With the override withdrawn, administrators hold
exactly what their groups were granted — so if the grants are not in place
first, nobody can operate the vault and the only way back in is break-glass.

Grants live per backend user group in **Backend Users > Groups**, field
**Custom module options**, group **Vault: operation
permissions**. The full table of ten permissions and what each governs is in
[Operation permissions](https://docs.typo3.org/permalink/netresearch/nr-vault:security-operation-permissions@1.0).

A workable starting split:

| Group | Grants |
| --- | --- |
| Editors | `secret.use` only. Required for FormEngine vault widgets and FlexForm / TypoScript placeholder resolution — without it, forms containing vault-backed fields break for non-admins. Deliberately **not** `secret.reveal`. |
| Vault operators | `secret.use`, `secret.reveal`, `secret.create`, `secret.rotate`, `secret.delete`. |
| Vault administrators | The operator set plus `secret.manage_policy` and `vault.configure`. |
| Auditors | `audit.view`, and `audit.export` only if they genuinely need to take the history off-system. |
| Key custodians | `master_key.rotate`. Keep this separate from everything else — it is the operation that can render the whole vault unreadable. |
| Integration accounts | `secret.use` only. An integration has no eyes; it must not gain `secret.reveal` as a side effect. |

> [!NOTE]
> `secret.use` and `secret.reveal` do not imply one another in either
> direction. A non-admin needs **both** for an end-to-end reveal: the
> endpoint asserts `secret.reveal`, and the shared read path asserts
> `secret.use`.

Remember that operation permissions are only one of two gates. Per-secret
ownership and group tiers still apply — see
[Access control](https://docs.typo3.org/permalink/netresearch/nr-vault:security-access-control@1.0).

> [!NOTE]
> The table above grants *named* backend groups. Under this profile
> `allowCliAccess = 1` is itself a **critical** doctor finding, so a
> deployment that leaves it on does not pass Step 7's gate.
>
> If it has to stay on anyway, the unattributed CLI actor is granted
> separately by [cliAllowedOperations](https://docs.typo3.org/permalink/netresearch/nr-vault:confval-ext-nrvault-cliallowedoperations@1.0), which defaults
> to `secret.use,secret.create,secret.rotate`. Keep it at or below that,
> and scope `cliAccessGroups` as well. Of the high-risk entries,
> `secret.reveal`, `secret.delete`, `master_key.rotate` and
> `audit.view` directly hand `vault:retrieve`, `vault:delete`,
> `vault:rotate-master-key` and `vault:audit` / `vault:audit-verify`
> to anyone with a shell on the host, under an actor the audit trail cannot
> name; `secret.manage_policy`, `audit.export` and `vault.configure`
> gate the corresponding backend actions — `secret.manage_policy` is
> listed because it edits `allowed_groups` / `write_groups`, so it is
> the entry that lets a grant widen itself. Prefer a named technical actor —
> [Technical actor context](https://docs.typo3.org/permalink/netresearch/nr-vault:developer-technical-actor-context@1.0) — for those workflows.
> `vault:doctor` reports the list as `cli.allowed_operations`.

## Step 4 — Enable an external audit sink {#step-4-enable-an-external-audit-sink}

The hardened profile **requires** one. Without an enabled and usable sink,
`vault:audit-verify` reports `NO_EXTERNAL_SINK`: the audit trail would
exist only in the database it is meant to protect, and no chain-tip anchor
could be published.

Cheapest sufficient configuration, if the host already has a log shipper:

**Extension configuration**

```none
auditSinkSyslogEnabled = 1
auditSinkSyslogIdent = nr-vault-prod

auditSinkFileEnabled = 1
auditSinkFilePath = /var/log/typo3/nr-vault-audit.ndjson
auditSinkAnchorPath = /var/log/typo3/nr-vault-anchors.ndjson
```

Enable the `file` sink even when shipping to syslog: it is what writes the
NDJSON anchor stream that `AnchorFileReader` reads back during
verification. Both paths must be outside any public root, or the sink reports
itself disabled rather than writing anyway.

For a webhook collector, read the `allowed_hosts` note in
[Monitoring and alerting](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-monitoring-and-alerting@1.0) before configuring the URL — a
private-address collector is refused by design.

## Step 5 — Schedule anchoring and verification {#step-5-schedule-anchoring-and-verification}

Two separate jobs, doing different things (see
[Scheduler tasks](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-monitoring-scheduler@1.0)):

```bash
vendor/bin/typo3 vault:audit-anchor    # publish the tip — hourly
vendor/bin/typo3 vault:audit-verify    # verify chain + anchor — every 15 min
```

Register `AuditAnchorTask` and `AuditVerifyTask` in
**Scheduler > Add task**, or run the commands from cron.

> [!NOTE]
> Anchoring asserts `vault.configure` and verification asserts
> `audit.view`, at both entry points — the command and the task. The
> scheduler form is granted through the `_cli_` administrator until step 6
> withdraws that bypass; the **cron** form is not, because it runs as the
> unattributed CLI actor and
> [cliAllowedOperations](https://docs.typo3.org/permalink/netresearch/nr-vault:confval-ext-nrvault-cliallowedoperations@1.0) excludes both permissions.
> Prefer the scheduler tasks here, and read step 6 before you choose cron.

Publish the first anchor by hand now, so verification has a baseline
immediately rather than after the first scheduled run:

```bash
vendor/bin/typo3 vault:audit-anchor
vendor/bin/typo3 vault:audit-verify
```

Wire the alerting at the same time — a scheduled check whose failures nobody
receives is not a control. [What to page on](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-monitoring-what-to-page@1.0) says what
to page on.

The external anchor above is only half of the control. A second, independent
tip anchor lives in `sys_registry` ([ADR-034: Audit chain tip anchor](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-034-audit-chain-tip-anchor@1.0))
and is what makes a full
wipe of `tx_nrvault_audit_log` detectable from inside the installation. It
arms itself on the next audit write; confirm it did, then require it:

```bash
vendor/bin/typo3 vault:audit --verify   # "Tip anchor: ok" — not "NOT ARMED"
```

> [!NOTE]
> `vault:audit --verify` asserts `audit.view`, the same permission as the
> listing form used further down — verification is a read of the chain.
> `audit.view` is not in the [cliAllowedOperations](https://docs.typo3.org/permalink/netresearch/nr-vault:confval-ext-nrvault-cliallowedoperations@1.0)
> default, and with the admin override disabled an admin no longer holds it
> implicitly — grant it to the group these checks run as, or run them as a
> named technical actor.
>
> The same applies to the two scheduled controls, and this is the profile
> where it bites. `scheduler:run` authenticates the `_cli_`
> administrator, so on a default installation the admin bypass grants both
> tasks; once the override is withdrawn it does not. Give the identity the
> scheduler runs as a group carrying `tx_nrvault:audit.view` (*Vault Audit
> Integrity Verification*) and `tx_nrvault:vault.configure` (*Vault Audit
> Chain Anchoring*) as part of this step. Both tasks fail loudly on a refusal
> rather than skipping quietly, so a missed grant shows up as a red scheduler
> entry — but a red anchoring task is a blind window that grows, so treat it
> as urgent.

**Extension configuration — only after the anchor reports `ok`**

```none
auditAnchorRequired = 1
```

Order matters. Turned on before the anchor is armed, every verification
reports a violation: an install that never had an anchor and one whose anchor
an attacker deleted look identical. Once on, the requirement lives in the
extension configuration rather than in a table, so a database-write attacker
can no longer silence the control by deleting the anchor row.
`vault:doctor` reports the state as `audit.db_anchor`.

Unlike `disableAdminOverride` and `frontendPlaceholderLegacyCli`, this
setting accepts **no** `$TYPO3_CONF_VARS` pin — only those three keys do —
so a compromised administrator can still clear it from the Settings module.
It closes the database-writer path, not the backend-administrator one.

## Step 6 — Withdraw the administrator override, and pin it {#step-6-withdraw-the-administrator-override-and-pin-it}

**Extension configuration**

```none
disableAdminOverride = 1
```

This withdraws the bypass in one place and therefore everywhere it was
consulted: the operation permissions, the per-secret read/write/delete tiers,
the privileged-column policy, and the technical-actor equivalents. An override
disabled in only some of those would be worse than none, because the
deployment would believe it is protected.

**Then pin it**, or the control is only as strong as the backend it is
configured in — a compromised administrator can untick a checkbox:

**config/system/additional.php**

```php
$GLOBALS['TYPO3_CONF_VARS']['SYS']['nrVault']['disableAdminOverride'] = true;

// Pin the strict CLI placeholder policy off-limits too (ADR-035). The
// value below is the DEFAULT — pinning false is what stops an
// administrator from turning the legacy CLI bypass back on. A deployment
// that genuinely needs the old behaviour pins true instead.
$GLOBALS['TYPO3_CONF_VARS']['SYS']['nrVault']['frontendPlaceholderLegacyCli'] = false;
```

Only three settings accept such a pin: `disableAdminOverride`,
`frontendPlaceholderLegacyCli` and `auditReads`. Everything else in the
hardened set stays editable from the Settings module by whoever holds
`vault.configure`.

`frontendPlaceholderLegacyCli` matters here because
`scheduler:run` authenticates the `_cli_` administrator: the admin
bypass grants the read, so the allow-set is the only remaining gate between an
editor-authored `tt_content` field and a secret in the output of a scheduled
newsletter or export job.

The pinned value wins in both directions and requires filesystem access to
change. Confirm it took effect:

```bash
vendor/bin/typo3 vault:break-glass --status
```

The output reports `adminOverrideDisabledEffective` alongside the raw
setting, so a "flag set, profile standard" mismatch is visible rather than
silent.

> [!NOTE]
> Ownership still applies. An administrator keeps full access to the secrets
> they own, exactly like any other user — which is what makes the disabled
> state workable day to day.

Exercise break-glass once, deliberately, before you need it:

```bash
vendor/bin/typo3 vault:break-glass --activate --reason="Verify break-glass path after hardening" --minutes=1
vendor/bin/typo3 vault:break-glass --status
vendor/bin/typo3 vault:break-glass --deactivate --reason="Verification complete"
vendor/bin/typo3 vault:audit --identifier=__break_glass__
```

Both rows must appear. See [Break-glass mode](https://docs.typo3.org/permalink/netresearch/nr-vault:security-break-glass@1.0) and
[Break-glass usage policy and review](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-incident-response-breakglass@1.0).

## Step 7 — Gate the deployment on `vault:doctor` {#step-7-gate-the-deployment-on-vault-doctor}

```bash
vendor/bin/typo3 vault:doctor --profile=hardened
```

Exit codes are the contract:

| Code | Meaning |
| --- | --- |
| `0` | Every control passed. Deploy. |
| `1` | Warnings only. Deployable, but each warning needs a decision and a ticket — do not normalise a permanently yellow gate. |
| `2` | At least one critical finding. **Do not deploy.** The hardened policy is not satisfied. An unusable `--profile` value and an internal crash also exit `2`, deliberately — a gate that could not check must never read as "checked and fine". |

Severity is worst-wins, so a long list of passes cannot average a critical
finding away.

Use it as an actual gate in the pipeline, and keep the JSON for the deployment
record:

**Deployment gate**

```bash
set -e
vendor/bin/typo3 vault:doctor --profile=hardened --format=json > vault-doctor.json
# A non-zero exit stops the deploy; the artefact goes to the release record.
```

The machine-readable output is what makes this auditable rather than a
screenshot: each finding carries a stable identifier, so a pipeline can assert
on specific findings and an auditor can compare runs over time. See
[Evidence collection](https://docs.typo3.org/permalink/netresearch/nr-vault:auditor-evidence-collection@1.0).

Run it periodically too, not only at deploy time —
[Periodic vault:doctor](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-monitoring-doctor@1.0).

## Step 8 — Smoke test {#step-8-smoke-test}

Verify the behaviour that actually changed. A green `doctor` says the
configuration is coherent; these steps say the deployment works.

**Crypto and provider**

-   -   **\[ \] A real secret decrypts: `vault:retrieve <identifier>`, or a reveal in**

        the backend module. Metadata listing is not proof.
-   -   **\[ \] Probe at least two secrets, ideally one from each encryption version —**

        see [Restore verification: the probe decrypt](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-backup-and-restore-verification@1.0).

**Reveal lifecycle**

-   -   **\[ \] A reveal shows the value and **no copy button** — the hardened profile**

        reports `copyAllowed = false`.
-   -   **\[ \] The value disappears after 30 seconds, and immediately when the tab is**

        switched away or the page is left.
-   \[ \] The reveal response carries `Cache-Control: no-store`.
-   -   **\[ \] Each reveal writes its own `read` audit row — reveal twice and check**

        for two rows.

**Permissions**

-   -   **\[ \] A non-admin editor with `secret.use` can load a form containing a**

        vault-backed field.
-   -   **\[ \] That editor **cannot** reveal (HTTP 403), and the denial appears as an**

        `access_denied` audit row.
-   -   **\[ \] An administrator without explicit grants cannot reach a secret they do**

        not own. **This is the check that proves step 6 took effect** — if they
        still can, the flag is not effective, and `--status` will show why.
-   -   **\[ \] The audit module is reachable by a group holding `audit.view` and not**

        by one without it.
-   -   **\[ \] The strict CLI placeholder policy is in force: `vault:doctor`**

        reports `cli.frontend_placeholder_legacy` as a pass (it is critical
        under this profile when the flag is on). Confirm it empirically as well —
        put a `%vault(id)%` placeholder for a `frontend_accessible` secret
        into an editor-editable field, run a scheduled render over it
        (`scheduler:run`), and confirm the placeholder does **not**
        resolve. If it does, `frontendPlaceholderLegacyCli` is on — check the
        pin.

**Audit pipeline**

-   \[ \] `vault:audit-verify` reports a valid chain and no findings.
-   -   **\[ \] An anchor exists and is recent — check the newest `timestamp` in the**

        anchor file.
-   -   **\[ \] `vault:audit --verify` reports `Tip anchor: ok`, and**

        `auditAnchorRequired` is on so a deleted anchor cannot silence the
        control.
-   -   **\[ \] Records actually arrive at the collector: run**

        `vault:doctor --active-probes` (every enabled sink must accept
        the chain-tip anchor end-to-end), then perform a reveal and look for it
        in syslog or the SIEM. Do not infer delivery from the absence of
        errors.
-   -   **\[ \] Break the delivery on purpose once (point the webhook at an**

        unreachable host, or make the NDJSON directory read-only), confirm a
        `SINK_FAILURE` alert reaches your alerting, then restore it. An
        untested alert path is not an alert path.

**Recovery**

-   \[ \] Break-glass opens and closes, and both rows are in the chain.
-   -   **\[ \] The key-material backup restores on a scratch system and a probe**

        decrypt succeeds there.

## Configuration summary {#configuration-summary}

**The hardened set, for review**

```none
securityProfile = hardened
disableAdminOverride = 1        # and pinned in additional.php

masterKeyProvider = file        # or env / transit — never typo3
masterKeySource = /var/lib/typo3-secrets/vault-master.key

allowCliAccess = 0              # default; 1 is a CRITICAL doctor finding
                                # under --profile=hardened, i.e. exit 2
cliAllowedOperations = secret.use,secret.create,secret.rotate
                                # default; only read when allowCliAccess = 1
frontendPlaceholderLegacyCli = 0  # default; pin it in additional.php
auditReads = 1
auditHmacEpoch = 3
auditAnchorRequired = 1         # only after the in-DB anchor reports ok

auditSinkSyslogEnabled = 1
auditSinkSyslogIdent = nr-vault-prod
auditSinkFileEnabled = 1
auditSinkFilePath = /var/log/typo3/nr-vault-audit.ndjson
auditSinkAnchorPath = /var/log/typo3/nr-vault-anchors.ndjson
```

Per-setting reference: [Configuration](https://docs.typo3.org/permalink/netresearch/nr-vault:configuration@1.0).
