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. |
| 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. |
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: to move providers. Use
vault: (Key rotation).
vault: generates a fresh key, and a fresh key over an existing
vault makes every secret unreadable — its own -- 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: 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. 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.. |
| Vault operators | secret., secret., secret.,
secret., secret.. |
| Vault administrators | The operator set plus secret. and
vault.. |
| Auditors | audit., and audit. only if they genuinely need to
take the history off-system. |
| Key custodians | master_. Keep this separate from everything else —
it is the operation that can render the whole vault unreadable. |
| Integration accounts | secret. only. An integration has no eyes; it must not gain
secret. as a side effect. |
Note
secret. and secret. do not imply one another in either
direction. A non-admin needs both for an end-to-end reveal: the
endpoint asserts secret., and the shared read path asserts
secret..
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
allow 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.. Keep it at or below that,
and scope cli as well. Of the high-risk entries,
secret., secret., master_ and
audit. directly hand vault:, vault:,
vault: and vault: / vault:
to anyone with a shell on the host, under an actor the audit trail cannot
name; secret., audit. and vault.
gate the corresponding backend actions — secret. is
listed because it edits allowed_ / write_, so it is
the entry that lets a grant widen itself. Prefer a named technical actor —
Technical actor context — for those workflows.
vault: reports the list as cli..
Step 4 — Enable an external audit sink
The hardened profile requires one. Without an enabled and usable sink,
vault: reports NO_: 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_ 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.
Note
Anchoring asserts vault. and verification asserts
audit., 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 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:
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_ (ADR-034: Audit chain tip anchor)
and is what makes a full
wipe of tx_ 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"
Note
vault: asserts audit., the same permission as the
listing form used further down — verification is a read of the chain.
audit. is not in the cliAllowedOperations
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: 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_ (Vault Audit
Integrity Verification) and tx_ (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.
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: reports the state as audit..
Unlike disable and frontend, this
setting accepts no $TYPO3_ 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: disable,
frontend and audit. Everything else in the
hardened set stays editable from the Settings module by whoever holds
vault..
frontend 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_ 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 admin 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 -- 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:, or a reveal inretrieve <identifier> - 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
copy.Allowed = 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.can load a form containing ause - vault-backed field.
- [ ] A non-admin editor with
- [ ] That editor cannot reveal (HTTP 403), and the denial appears as an
access_audit row.denied
- [ ] 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
--will show why.status
- [ ] The audit module is reachable by a group holding
audit.and notview - 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.as a pass (it is critical under this profile when the flag is on). Confirm it empirically as well — put afrontend_ placeholder_ legacy %vaultplaceholder for a(id)% frontend_secret into an editor-editable field, run a scheduled render over it (accessible scheduler:), and confirm the placeholder does not resolve. If it does,run frontendis on — check the pin.Placeholder Legacy Cli
- [ ] The strict CLI placeholder policy is in force:
Audit pipeline
- [ ]
vault:reports a valid chain and no findings.audit- verify - [ ] An anchor exists and is recent — check the newest
timestampin the - anchor file.
- [ ] An anchor exists and is recent — check the newest
- [ ]
vault:reportsaudit -- verify Tip anchor: ok, and auditis on so a deleted anchor cannot silence the control.Anchor Required
- [ ]
- [ ] 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_alert reaches your alerting, then restore it. An untested alert path is not an alert path.FAILURE
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.