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 first for what the profile changes, and Known limitations 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 — the sequence there rotates the key before switching the profile, which is the part that matters.

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.

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.
Extension configuration — file provider
masterKeyProvider = file
masterKeySource = /var/lib/typo3-secrets/vault-master.key
Copied!

Initialise the key if this is a new vault:

vendor/bin/typo3 vault:init
Copied!

Back up the new key material now, separately from the database, and verify the backup: Backup and restore.

Step 2 — Set the profile 

Extension configuration
securityProfile = hardened
Copied!

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:

vendor/bin/typo3 vault:list
vendor/bin/typo3 vault:doctor --profile=hardened
Copied!

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 

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.

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.

Remember that operation permissions are only one of two gates. Per-secret ownership and group tiers still apply — see Access control.

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
auditSinkSyslogEnabled = 1
auditSinkSyslogIdent = nr-vault-prod

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

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 before configuring the URL — a private-address collector is refused by design.

Step 5 — Schedule anchoring and verification 

Two separate jobs, doing different things (see Scheduler tasks):

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

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

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

vendor/bin/typo3 vault:audit-anchor
vendor/bin/typo3 vault:audit-verify
Copied!

Wire the alerting at the same time — a scheduled check whose failures nobody receives is not a control. What to page on 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) 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:

vendor/bin/typo3 vault:audit --verify   # "Tip anchor: ok" — not "NOT ARMED"
Copied!
Extension configuration — only after the anchor reports ok
auditAnchorRequired = 1
Copied!

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 

Extension configuration
disableAdminOverride = 1
Copied!

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
$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;
Copied!

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:

vendor/bin/typo3 vault:break-glass --status
Copied!

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

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

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__
Copied!

Both rows must appear. See Break-glass mode and Break-glass usage policy and review.

Step 7 — Gate the deployment on vault:doctor 

vendor/bin/typo3 vault:doctor --profile=hardened
Copied!

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
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.
Copied!

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.

Run it periodically too, not only at deploy time — Periodic vault:doctor.

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.

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 

The hardened set, for review
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
Copied!

Per-setting reference: Configuration.