Security
The sections on this page are the security overview. The pages listed below go deeper on the parts an assessment or a hardened deployment needs.
Warning
Read Known limitations before relying on any control described here. It states, honestly, where each defence stops — including the ones that cannot be fixed inside a TYPO3 extension.
Encryption architecture
nr-vault uses envelope encryption, an industry-standard pattern for protecting sensitive data.
+-------------------+
| Master Key |
+--------+----------+
|
| encrypts
|
+-----+------+--------+
| | |
v v v
+------+ +------+ +------+
| DEK1 | | DEK2 | | DEK3 |
+--+---+ +--+---+ +--+---+
| | |
| encrypts | encrypts | encrypts
v v v
+------+ +------+ +------+
|Value1| |Value2| |Value3|
+------+ +------+ +------+
Secret 1 Secret 2 Secret 3
How it works
- Data Encryption Key (DEK): Each secret gets a unique 256-bit key generated using cryptographically secure random bytes.
- Value encryption: The secret value is encrypted with its DEK using AES-256-GCM (or XChaCha20-Poly1305).
- DEK encryption: The DEK is encrypted with the Master Key and stored alongside the encrypted value.
- Decryption: To read a secret, first decrypt the DEK with the Master Key, then use the DEK to decrypt the value.
Benefits
- Key rotation: Rotating the master key only requires re-encrypting DEKs, not the actual secret values.
- Blast radius: If a DEK is compromised, only one secret is affected.
- Performance: Bulk operations on secrets don't require the master key for each operation.
Algorithms
- XChaCha20-Poly1305 (default)
- ChaCha20 stream cipher with extended nonce and Poly1305 MAC. The deliberate default for new secrets: available in every libsodium build, and its 24-byte nonce makes random-nonce collisions a non-concern, so vault contents stay portable across hosts with differing CPU capabilities.
- AES-256-GCM (opt-in)
- Advanced Encryption Standard with 256-bit keys in Galois/Counter Mode. Selected per installation via encryptionAlgorithm, and only worth choosing on hosts with hardware AES support.
Both algorithms provide:
- 256-bit key strength.
- Authenticated encryption (AEAD).
- Protection against tampering.
Master key security
The master key is the root of trust for all secrets.
Provider security comparison
- TYPO3 provider (default, recommended for most users)
- Security depends on TYPO3's encryption key protection. Suitable for
environments where the encryption key is properly secured in
settings.. No additional configuration required.php - File provider (recommended for high-security environments)
- Allows storing the key outside the database and web root with strict permissions. Requires server access to configure.
- Environment provider (recommended for containers)
- Ideal for containerized deployments where secrets are injected at runtime. Follows 12-factor app methodology.
File storage recommendations
When using the file provider:
- Outside web root: Never store in publicly accessible directories.
- Restrictive permissions: Use 0400 (read-only by owner).
- Separate backup: Back up the master key separately from the database.
- Access logging: Monitor access to the key file.
- Key rotation: Rotate the master key periodically.
Warning
If the master key is compromised, all secrets must be considered compromised. Rotate the master key and all secrets immediately.
Audit logging
All secret operations are logged with:
- Timestamp.
- Action (create, read, update, delete).
- Actor (user ID, username, type).
- Secret identifier.
- IP address.
- Result (success/failure).
Hash chain integrity
Audit log entries form a hash chain where each entry includes a hash of the previous entry. This provides:
- Tamper detection: Any modification to log entries breaks the chain.
- Completeness: An entry deleted from the middle of the log leaves a UID
gap, which the verifier reports as an error even if the surrounding
previous_hashvalues were patched to match. - Non-repudiation: Actions cannot be denied after logging.
Removing the tail of the log leaves no gap and no broken link, so the chain walk alone cannot see it. That case is covered by the tip anchor below.
Tip anchor (truncation detection)
The chain proves that what is stored was not altered. It cannot, on its own,
prove how much should be stored — any counter kept inside
tx_nrvault_audit_log is deleted along with the rows.
nr_vault therefore records one signed assertion outside that table, in the core
table sys_registry:
Audit row
uid = Astill exists and itsentry_hashis stillH.
The assertion is authenticated with HMAC-SHA256 under a key derived from the
master key with its own HKDF context string ("nr-vault-audit-anchor-v1"),
which is not stored in the database. It advances on every audit write, inside
the same transaction as the audit row, and is re-recorded by master-key rotation
and by both HMAC re-seal paths. See ADR-034: Audit chain tip anchor.
What this adds: DELETE FROM tx_nrvault_audit_log WHERE uid > N, deletion of
the last row, TRUNCATE, and a truncate followed by refilling the same UIDs
with fresh entries are all reported as an invalid chain — by
vault:audit --verify, by the backend verification view, and by the gates
that guard master-key rotation and the HMAC migration.
What this does not add: an attacker with database write access who deletes the
sys_registry row before truncating returns the installation to
undetectable truncation. That is reported as a warning (`Tip anchor: NOT
ARMED`) rather than an error, because an installation that has not yet written
an audit entry since the upgrade is indistinguishable from one whose anchor was
deleted.
auditAnchorRequired
Setting auditAnchorRequired = 1 in the extension configuration is the
operator's assertion that this installation is already anchored. It is off by
default, and it changes two things:
- verification reports a missing anchor as an error instead of a warning;
- an ordinary audit write no longer creates an anchor that is not there — at all, whatever the log currently contains.
The second half is what makes the first half worth anything. An attacker with database write access can delete the anchor row — or merely blank its value — and truncate the log; if the next audit write then armed a fresh anchor on the shortened chain, the error would disappear within seconds and the installation would report a valid chain permanently, now with a signed attestation that the truncated tip is genuine.
The refusal has to be unconditional, and that is a deliberate change of behaviour rather than a strictness for its own sake. "The log still holds an earlier entry" is not a usable test for "this anchor was never armed": the audit write that triggers the check has just inserted the only row the chain has, so a log emptied outright looks exactly like a brand-new installation. Anything that armed on an apparently-fresh chain would be bypassed by deleting every audit row, which is easier than deleting some.
So with the setting on, arming is always an explicit operator action:
vault:audit --reset-anchor, which writes the reset into the chain. That
includes the very first arming — an installation that enables the setting before
its anchor exists reports Tip anchor: NOT ARMED as an error until the command
is run. This is why the setting is off by default and why the documented order
matters: enable it once, after the first audit write following the upgrade.
The setting is worth having because extension configuration lives in a settings
file: an attacker who only has database write access cannot turn it back off.
Blanking the anchor value (UPDATE sys_registry SET entry_value = NULL) is
not a way around this at any setting: a row that is present but unreadable is an
Unreadable error, and it is never repaired by an audit write.
Restoring a legitimately wiped log
After a deliberate purge or wipe, the anchor stays behind and reports a violation permanently. Clear it with:
vendor/bin/typo3 vault:audit --reset-anchor
The command records the reset as an audit entry in the same transaction, so it
cannot be performed invisibly, and re-arms the anchor on that entry. It is the
only path that arms an anchor at all while auditAnchorRequired is enabled.
HMAC-keyed audit chain
The audit hash chain is authenticated with HMAC-SHA256, using a key derived from the master key via HKDF (see ADR-023: Audit hash chain HMAC consideration). This provides adversarial tamper resistance in addition to tamper detection:
- Adversarial resistance: An attacker with database access but without the master key cannot forge valid HMAC values or recompute the hash chain.
- Cryptographic separation: The HMAC key is derived with a dedicated
context string (
"nr-vault-audit-hmac-v1"), ensuring independence from encryption key material. - Backward compatibility: Legacy entries (epoch 0) created before the HMAC migration remain verifiable using the original SHA-256 algorithm. New entries (epoch 1+) use HMAC-SHA256.
Use the vault:audit-migrate-hmac command to migrate existing legacy
entries to HMAC-SHA256. See vault:audit-migrate-hmac for details.
Access control
Access is decided by two independent gates. Both must pass.
Per-secret access answers "may this actor touch this secret?":
- Authentication: Backend user must be logged in.
- Ownership: Creator has full access.
- Group membership: Shared access via backend groups.
- Admin override: Administrators can access all secrets.
Operation permissions answer "may this actor perform this kind of operation at all?" — see the next section.
Note
CLI access requires explicit configuration and can be restricted to specific groups.
Operation permissions
Each vault operation has its own permission, granted per backend user
group in the Backend Users module (field Custom module options,
group Vault: operation permissions). They are registered as TYPO3
custom permission options under
$GLOBALS['TYPO3_CONF_VARS']['BE']['customPermOptions']['tx_nrvault']
and checked server-side via
AccessControlServiceInterface::isGranted().
| Permission | Governs |
|---|---|
tx_nrvault:secret.use | Programmatic consumption of a plaintext value (FormEngine vault widgets, FlexForm/TCA placeholders, site config, HTTP clients). |
tx_nrvault:secret.reveal | Displaying a plaintext to a human (the vault_reveal
endpoint, vault:retrieve). |
tx_nrvault:secret.create | Creating new secrets. |
tx_nrvault:secret.rotate | Replacing the value of an existing secret. |
tx_nrvault:secret.delete | Deleting secrets. |
tx_nrvault:secret.manage_policy | Enabling/disabling secrets and editing their
allowed_groups / write_groups tiers. |
tx_nrvault:audit.view | Reading the audit log, its usage analytics, and verifying the hash chain. |
tx_nrvault:audit.export | Downloading the audit log (JSON / CSV). |
tx_nrvault:master_key.rotate | Rotating the master key. |
tx_nrvault:vault.configure | Running the migration wizard, and seeing the detailed
vault:doctor finding list in the Overview module. |
Notes on the model:
- ``secret.use`` does not imply ``secret.reveal``, and neither
implies the other. A non-admin needs both for an end-to-end
reveal: the endpoint asserts
secret.reveal(displaying plaintext), and the shared read path assertssecret.use(obtaining it at all). An integration account getssecret.useonly. - Non-admin backend users need ``secret.use`` for every plaintext read, including FormEngine vault field widgets and FlexForm / TypoScript placeholder resolution. Grant it to the groups whose editors work with vault-backed fields.
- Mutations are enforced centrally in the service, not only in the
module controllers.
VaultService::store()requiressecret.create(new identifier) orsecret.rotate(existing identifier, plussecret.manage_policywhen the call also changes owner, group tiers or frontend availability);rotate()requiressecret.rotate;delete()requiressecret.delete. A direct DataHandler/FormEngine request or a programmatic caller therefore faces the same gates as the secrets module. Editors whose forms write vault-backed TCA fields needsecret.create/secret.rotatein addition tosecret.use. Creating atx_nrvault_secretrecord assertssecret.createeven when no value is submitted. That one case cannot go through the service — no value means nostore()call — soSecretgates it inTca Hook processDatamap_preProcessFieldArray(), refusing the record before DataHandler inserts it rather than deleting it afterwards. - Technical actors (
TechnicalActorContext::runAs()) holdsecret.useimplicitly — headless consumption is their purpose — and every other operation permission only if one of their provisioned backend groups grants it via the sametx_nrvault:*custom permission options. - Admins and system maintainers hold every permission
unconditionally, and have full per-secret access to every secret.
That override lives in a single seam
(
AccessControlService::adminBypassActive()) and can be removed — see Disabling the admin override. - The backend modules are registered ``access => 'user'``. That is
deliberate: authorization is asserted by each controller action, not
by the module registration, so granular grants are usable by
non-admins. The same holds for the
vault_reveal/vault_rotateAJAX routes. - CLI: a trusted CLI operator has no backend user record and thus no
group grants. Operation permissions follow the vault's
allowCliAccess switch (off by default),
narrowed to the
cliAllowedOperationsallowlist (default:secret.use,secret.create,secret.rotate). High-risk operations are excluded by default:vault:retrieve(needssecret.reveal),vault:delete/ the scheduled orphan cleanup (secret.delete),vault:rotate-master-key(master_key.rotate) and the audit export require adding the respective operation to the allowlist — or, preferably, a named technical actor viaTechnicalActorContext::runAs(). - Frontend requests never hold operation permissions, regardless of
any backend session the visitor carries — frontend visibility remains
a property of the secret (
frontend_accessible) alone. For%vaultplaceholder resolution there is a second gate on top: the identifier must also be in the request's FrontendPlaceholderPolicy allow-set (ADR-035: Per-request allow-set of frontend-resolvable identifiers), which the CLI enforces too unless frontendPlaceholderLegacyCli is set.(id)%
Disabling the admin override
By default a TYPO3 administrator holds every vault permission and full
read/write/delete access to every secret. For most installations that is
the right answer: an admin already controls settings., the
master-key provider and the extension configuration, so withholding
vault permissions from them would be theatre.
It stops being theatre in two situations: an installation where "TYPO3 administrator" and "may read production credentials" are genuinely different roles, and an audit regime that requires every plaintext access to be attributable to a granted permission rather than to a role. For those, set
securityProfile = hardened
disableAdminOverride = 1
and administrators are treated like every other backend user: they hold exactly the operation permissions their groups were granted, and reach only the secrets they own or share a group with.
What is removed
Both gates, in one place. The override is a single private seam
(AccessControlService::adminBypassActive()) consulted by:
isGranted()— the operation permissions;canRead()/canWrite()/canDelete()— the per-secret tiers;isCurrentActorAdmin()— the privileged-column policy in the TCA hook and thesecret.use/owner_uid/frontend_accessibleexemptions inVaultService;- the technical-actor equivalents of all of the above, so a
runAs()snapshot carrying the admin flag does not keep what the interactive admin lost.
An override that were removed from only some of these would be worse than none, because the deployment would believe it is protected.
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.
Two deliberate constraints
The flag only takes effect in the hardened profile. In the standard
profile it is inert. Setting it alone, without the rest of the hardened
policy (an explicit external master-key provider, no fallback to the
TYPO3 encryption key), is far more likely to be a misunderstanding than
a decision — and its failure mode is locking every administrator out of
the vault. Choosing hardened is the explicit statement that the
fail-closed contract has been read. Run
vault:break-glass --status to see whether
the flag is effective; it reports adminOverrideDisabledEffective
alongside the raw setting, so a "flag set, profile standard" mismatch is
visible rather than silent.
Pin the value outside the backend. The setting is editable in
Admin Tools > Settings, which means a compromised admin
could untick it. Pin it in config/, where
only filesystem access can change it:
$GLOBALS['TYPO3_CONF_VARS']['SYS']['nrVault']['disableAdminOverride'] = true;
The pinned value wins in both directions and is the same mechanism auditReads uses.
Break-glass mode
A disabled override needs an escape hatch, or the first genuine incident becomes an outage. Break-glass mode is that hatch: a deliberate, justified, time-boxed restoration of the admin override.
# 1. Confirm the state
vendor/bin/typo3 vault:break-glass --status
# 2. Open a window with a justification
vendor/bin/typo3 vault:break-glass --activate --reason="INC-4711 rotate leaked deploy key" --minutes=30
# 3. Do the work in the backend or on the CLI
# 4. Close it — do not wait for the expiry
vendor/bin/typo3 vault:break-glass --deactivate --reason="INC-4711 closed"
Who may open a window
Only a real backend administrator or system maintainer — the actual
TYPO3 isAdmin() flag, checked independently of the disabled override
so the escape hatch is reachable in the very state it exists for — or an
operator in a real CLI context. Break-glass is deliberately not
gated on a VaultPermission: it exists to recover from a state where
the granular grants are what is missing, so gating it on one would make
it unreachable exactly when it is needed. CLI is likewise not gated on
allowCliAccess — a shell on the host
already reaches the master key.
A TechnicalActorContext::runAs() scope may never open a window,
even for an actor whose snapshot carries the admin flag. runAs() is
not an authentication boundary (any code with DI access can open a
scope), so accepting it would let arbitrary extension code mint its own
bypass with a synthetic justification.
Mandatory justification
--reason is required for both activation and deactivation, and an
empty or whitespace-only value is rejected. The reason is stored
verbatim in the audit row, carried in the PSR-14 event, and displayed in
the backend banner. Reference the incident, ticket or change record —
"testing" tells a later reviewer nothing.
Time boxing
The window defaults to 15 minutes and is clamped to 1..60. Out-of-range
values are clamped rather than rejected: a fat-fingered --minutes=600
during an incident should yield the one-hour ceiling, not an error to
re-read under pressure.
Expiry is evaluated at read time, on every access-control decision. There is no scheduled task to close a window, and therefore no stalled cron job that can silently extend one. A forgotten window stops granting anything the moment it lapses.
Audit evidence
Activation and deactivation each write one row to the tamper-evident
audit log under the pseudo-identifier __break_glass__ (the same
convention vault:rotate-master-key uses for __master_key__):
| Action | Written when |
|---|---|
break_glass_activated | A window is opened. Context carries the actor, the expiry and the TTL. |
break_glass_deactivated | A window is closed early. Context also carries the original activation reason. |
Both rows are sealed into the HMAC hash chain like any other entry, so the evidence cannot be edited away without breaking verification. The activation row is written before the window opens: the two stores cannot be updated atomically, and only that order makes "window open without evidence" impossible.
A window that simply expires writes no row — nothing runs at the
moment it lapses. Reconstruct the closed interval from the activation
row's expiresAt context value.
For alerting, listen to
Netresearch\NrVault\Event\BreakGlassActivatedEvent and
BreakGlassDeactivatedEvent. The audit log proves what happened; a
listener is what makes someone look.
Visible warning
While a window is open, the vault Overview and Secrets modules show a danger callout naming who opened it, the stated reason, and when it expires. Visibility is half the control — a window nobody notices is just the admin override with extra steps.
Warning
Break-glass restores full admin power. While a window is open, an administrator has exactly what they had before the override was disabled: every operation permission, and read/write/delete on every secret. Break-glass prevents nothing.
Its value is evidence and time boxing — a named actor, a typed justification, a hash-chained audit row, an event observers can alert on, a banner every operator sees, and an expiry nobody has to remember. Treat an activation as an incident to review, not as routine maintenance.
Technical actors
Headless code (messenger workers, scheduler runs) can act as a named technical backend user through the scoped TechnicalActorContext::runAs() API instead of the global CLI switch.
Threat-model notes:
runAs()is not an authentication boundary: any PHP code with DI access can act as any enabled backend user — the same power that$GLOBALS['BE_USER']mutation already grants every installed extension. Its security value is validation (deleted/disabled/time-restricted users are refused), guaranteed scope restoration (also on exceptions), and honest audit attribution.$GLOBALS['BE_USER']is never mutated, so a technical identity cannot leak into other code sharing the PHP process.- Audit entries written inside a scope carry `
actor_` plus the actor's uid and username, sealed into the HMAC hash chain — impersonation is always attributable and tamper-evident.type = 'technical' - Restrict the technical user like any backend account: no admin flag
unless required, minimal group membership, and monitor its
access_deniedevents.
Deployment gate
Every control described on this page is checkable from a shell. vault:doctor evaluates them all and reduces the result to a process exit code, which makes it usable as the last step of a deployment pipeline rather than as a document somebody is supposed to have read.
vendor/bin/typo3 vault:doctor --profile=hardened
Exit-code contract
| Code | Meaning |
|---|---|
0 | Every control passed — audit-ready for the checked profile. |
1 | Warnings only. Deployable; fix before an audit. |
2 | At least one critical finding, an unusable --profile
value, or the run could not complete. |
The verdict is the worst severity present, never an average, so a long list
of passing controls cannot offset one critical finding. And 2 covers "could
not check" as well as "checked and found a problem" — a gate that cannot run must
never be readable as a gate that found nothing.
Two ways to wire it, and they answer different questions:
vendor/bin/typo3 vault:doctor
test $? -le 1
vendor/bin/typo3 vault:doctor
The stricter form is the one to aim for. Accepting exit 1 indefinitely means
a warning nobody ever removes, which is the state in which a new warning goes
unnoticed.
Checking a profile you have not adopted yet
--profile=hardened evaluates the live configuration against the hardened
policy without changing anything. That is how to plan the migration described
in Disabling the admin override — from the actual finding list, rather
than by switching the profile on production and finding out which service stops
booting.
The report always states both the profile it checked and the profile in force, so a passing dry run cannot be mistaken for hardening already being live.
What the gate does not cover
vault:doctor bounds its own cost so it can run in a pipeline and on a backend
page load. Two limits matter, and both are stated in the findings themselves:
- the hash-chain pass covers the newest 1000 audit entries, not the whole chain;
- the anchor comparison detects a chain that has shrunk, not one whose anchored row now hashes differently.
vault:audit-verify does both in full and is the authoritative integrity verifier. Schedule it — the gate is a pre-flight check, not a substitute for continuous verification.
Backend surface
The vault Overview module shows the same controls: the active profile, an
"N of M controls passed" ratio, and the open findings with their risk and
remediation. The detailed finding list requires the vault.configure
permission — it names this installation's concrete weak points and the files to
edit. Everyone else sees the profile badge and the ratio, which is enough to
escalate.
Security best practices
- Regular key rotation: Rotate the master key annually or after security incidents.
- Audit log review: Regularly review audit logs for suspicious access.
- Minimal permissions: Grant access only to users who need it.
- Secret rotation: Rotate secrets when personnel changes occur.
- Monitoring: Set up alerts for access_denied events.
- Backup security: Encrypt backups and store them securely.
Reporting vulnerabilities
If you discover a security vulnerability, please report it responsibly:
DO NOT create a public GitHub issue.
Use GitHub's private security reporting feature: Report a vulnerability
See SECURITY. for the full security policy.