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.
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
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. |
masterKeyProvider = file
masterKeySource = /var/lib/typo3-secrets/vault-master.key
Initialise the key if this is a new vault:
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).
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.
Step 2 — Set the profile
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:
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
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. |
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.
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, 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 and master_key.rotate directly
hand vault:retrieve, vault:delete and vault:rotate-master-key
to anyone with a shell on the host, under an actor the audit trail cannot
name; audit.export and vault.configure gate the corresponding
backend actions. Prefer a named technical actor —
Technical actor context — for those workflows.
vault:doctor reports the list as cli.allowed_operations.
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:
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
Anchor 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
Register
Audit and
Audit 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
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"
ok
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
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:
$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: 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
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:
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 and Break-glass usage policy and review.
Step 7 — Gate the deployment on vault:doctor
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:
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.
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.
- [ ] A real secret decrypts:
- [ ] 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
readaudit row — reveal twice and check - for two rows.
- [ ] Each reveal writes its own
Permissions
- [ ] A non-admin editor with
secret.usecan load a form containing a - vault-backed field.
- [ ] A non-admin editor with
- [ ] That editor cannot reveal (HTTP 403), and the denial appears as an
access_deniedaudit 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
--statuswill show why.
- [ ] The audit module is reachable by a group holding
audit.viewand not - by one without it.
- [ ] The audit module is reachable by a group holding
- [ ] The strict CLI placeholder policy is in force:
vault:doctor - reports
cli.frontend_placeholder_legacyas a pass (it is critical under this profile when the flag is on). Confirm it empirically as well — put a%vault(id)%placeholder for afrontend_accessiblesecret into an editor-editable field, run a scheduled render over it (scheduler:), and confirm the placeholder does not resolve. If it does,run frontendPlaceholderLegacyCliis on — check the pin.
- [ ] The strict CLI placeholder policy is in force:
Audit pipeline
- [ ]
vault:audit-verifyreports a valid chain and no findings. - [ ] An anchor exists and is recent — check the newest
timestampin the - anchor file.
- [ ] An anchor exists and is recent — check the newest
- [ ]
vault:audit --verifyreportsTip anchor: ok, and auditAnchorRequiredis on so a deleted anchor cannot silence the control.
- [ ]
- [ ] Records actually arrive at the collector: run
vault:(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.doctor -- active- probes
- [ ] Break the delivery on purpose once (point the webhook at an
- unreachable host, or make the NDJSON directory read-only), confirm a
SINK_FAILUREalert 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
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.