TCA integration
nr-vault provides a custom TCA field type that allows any TYPO3 extension to store sensitive data (API keys, credentials, tokens) securely in the vault instead of plaintext in the database.
Table of contents
Quick start
Step 1: Add dependency
Add nr-vault as a dependency in your extension's composer.:
{
"require": {
"netresearch/nr-vault": "^0.13"
}
}
Step 2: Configure TCA field
Use the vault renderType in your TCA configuration:
<?php
return [
'ctrl' => [
'title' => 'My Extension Settings',
// ... other ctrl settings
],
'columns' => [
'api_key' => [
'label' => 'API Key',
'config' => [
'type' => 'input',
'renderType' => 'vaultSecret',
'size' => 30,
'searchable' => false,
],
],
],
];
'searchable' => false keeps the column out of the TYPO3 v14 backend
search, which otherwise searches every input column. On TYPO3 v13, which
ignores the flag, leave the column out of ctrl..
Step 3: Add database column
Add the column to your extension's ext_tables.sql:
CREATE TABLE tx_myext_settings (
api_key varchar(255) DEFAULT '' NOT NULL
);
The column stores the vault identifier, not the actual secret.
Step 4: Retrieve secrets in code
Use the
Vault utility to retrieve actual secret values:
use Netresearch\NrVault\Utility\VaultFieldResolver;
class MyService
{
// VaultFieldResolver is a DI service — inject it, never call it
// statically.
public function __construct(
private readonly VaultFieldResolver $vaultFieldResolver,
) {}
public function callExternalApi(array $settings): void
{
// Resolve vault identifiers to actual values
$resolved = $this->vaultFieldResolver->resolveFields(
$settings,
['api_key', 'api_secret']
);
// Now $resolved['api_key'] contains the actual secret
$client->authenticate($resolved['api_key']);
}
}
Reveal failure recovery
The secret-input widget resets failed reveal attempts before displaying an error notification. It wipes a partially displayed value, cancels any registered reveal lifecycle, restores the original button content and enables the reveal control. The click boundary also catches unexpected Promise rejections and writes a fixed fallback diagnostic without exception details.
Using the TCA helper
For cleaner TCA configuration, use the
Vault:
<?php
use Netresearch\NrVault\TCA\VaultFieldHelper;
return [
'columns' => [
'api_key' => VaultFieldHelper::getFieldConfig([
'label' => 'API Key',
'description' => 'Your API authentication key',
'size' => 30,
]),
// Secure field with common defaults (exclude: true, l10n_mode: exclude)
'api_secret' => VaultFieldHelper::getSecureFieldConfig(
'API Secret',
['required' => true]
),
],
];
Available options
| Option | Type | Description |
|---|---|---|
label | string | Field label. |
description | string | Field description/help text. |
size | int | Input field size (default: 30). |
required | bool | Whether field is required (default: false). |
placeholder | string | Placeholder text. |
display | string | TCA display condition. |
l10n_ | string | Localization mode. |
exclude | bool | Exclude from non-admin access. |
Every column the helper builds sets 'searchable' => false, so the TYPO3
v14 backend search leaves it out.
FlexForm integration
Vault secrets also work in FlexForm fields:
<T3DataStructure>
<sheets>
<settings>
<ROOT>
<el>
<apiKey>
<label>API Key</label>
<config>
<type>input</type>
<renderType>vaultSecret</renderType>
<size>30</size>
</config>
</apiKey>
</el>
</ROOT>
</settings>
</sheets>
</T3DataStructure>
Resolve FlexForm secrets using
Flex:
use Netresearch\NrVault\Utility\FlexFormVaultResolver;
use TYPO3\CMS\Core\Service\FlexFormService;
class MyPlugin
{
public function __construct(
private readonly FlexFormService $flexFormService,
private readonly FlexFormVaultResolver $flexFormVaultResolver,
) {}
public function processSettings(array $contentElement): array
{
$settings = $this->flexFormService->convertFlexFormContentToArray(
$contentElement['pi_flexform']
);
// Resolve specific fields
return $this->flexFormVaultResolver->resolveSettings(
$settings,
['apiKey', 'apiSecret']
);
// Or resolve all vault identifiers automatically
return $this->flexFormVaultResolver->resolveAll($settings);
}
}
VaultFieldResolver API
The
Vault class provides utilities for working with
vault-backed TCA fields.
Important
Vault and
Flex are
final readonly DI services with instance methods. Inject them via
the constructor; calling any of the methods below statically is a fatal
error. The examples show the call on an injected
$this->vault.
resolveFields()
Resolve specific fields in a data array:
$resolved = $this->vaultFieldResolver->resolveFields(
$data, // Array with potential vault identifiers
['field1'], // Fields to resolve
false // Throw on error (default: false)
);
resolve()
Resolve a single vault identifier (UUID v7 format):
// TCA field identifiers use UUID v7 format
$secret = $this->vaultFieldResolver->resolve('01937b6e-4b6c-7abc-8def-0123456789ab');
resolveRecord()
Automatically resolve all vault fields in a record based on TCA:
$resolved = $this->vaultFieldResolver->resolveRecord('tx_myext_settings', $record);
isVaultIdentifier()
Check if a value is a vault identifier:
if ($this->vaultFieldResolver->isVaultIdentifier($value)) {
// This is a vault identifier
}
getVaultFieldsForTable()
Get list of vault field names for a table:
$fields = $this->vaultFieldResolver->getVaultFieldsForTable('tx_myext_settings');
// Returns: ['api_key', 'api_secret']
hasVaultFields()
Cheap check for whether a table has any vault-backed field at all — use it to skip resolution work entirely rather than resolving an empty field list:
if ($this->vaultFieldResolver->hasVaultFields('tx_myext_settings')) {
// ... resolve
}
getFlexFieldsForTable()
The FlexForm counterpart, on
Flex: the FlexForm
columns of a table whose data structure declares vault fields.
$flexFields = $this->flexFormVaultResolver->getFlexFieldsForTable('tt_content');
How it works
Data flow
- Form display: The
Vaultrenders an obfuscated password field with a reveal button, plus a copy button outside the hardened profile.Secret Element -
Form submit: The
Dataintercepts the form data:Handler Hook - Extracts the secret value from the form.
- Generates a UUID v7 identifier (time-ordered, unique).
- Stores the secret in the vault with metadata (table, field, uid).
- Saves only the UUID identifier to the database.
- Runtime retrieval: Your code uses
Vaultto look up the actual secret from the vault using the UUID.Field Resolver
Identifier format
TCA and FlexForm fields use UUID v7 identifiers:
01937b6e-4b6c-7abc-8def-0123456789ab
UUID v7 provides:
- Time-ordering: Better B-tree index performance in databases.
- Uniqueness: Collision-free without central coordination.
- Security: Does not expose table/field names in the identifier.
The source context (table, field, uid) is stored as metadata in the vault, not in the identifier itself.
Tip
See ADR-001: UUID v7 for secret identifiers for the full rationale behind this design decision.
Record operations
- Create: New vault secret is stored automatically.
- Update: Secret is rotated (maintains audit trail).
- Delete: Vault secrets are removed when the record is deleted. FlexForm cascades act on hard deletion; soft-delete/recycle retains their secrets for record restoration.
- Copy: Vault secrets are cloned to the new record under fresh identifiers.
Records with several vault fields are handled as one unit, with the residual cases named below.
A create whose secret value is refused is compensated rather than left
half-applied: the just-inserted tx_ row is removed, the
record's field value is rolled back, and no success audit entry survives — a
refused create leaves nothing behind that would later read as a successful
one. Only a genuinely value-less record is audited as created. The mutation
and its audit entry commit together throughout, including the MM rows behind
allowed_ and write_: DataHandler writes those before the
completion hook runs, so a snapshot taken beforehand is what restores them if
the audit write fails.
A copy gives the duplicate independent secret references. The plain TCA hook compensates across its recognized vault fields. FlexForm compensation currently covers each column: if cloning, final XML serialization or its link write fails, successfully stored clones of that column are abandoned and all recognized vault positions in the duplicate's column are cleared. Other FlexForm data is preserved. Whole-record coordination across several FlexForm columns or plain/FlexForm hooks remains separate audit work; this boundary does not make every record copy atomic (see ADR-046: Compensate failed final FlexForm copy links).
For regular TCA vault fields, the same compensation applies if storing the fresh identifiers in the copied record fails after the secrets were cloned. Every created clone is considered for deletion, and clearing the copied record's vault fields is attempted. An exception or PHP error during that clearing attempt is reported as a failure requiring manual review.
Both cleanup and clearing are best-effort. Successful clearing with failed clone deletion can leave an unreferenced clone requiring reconciliation. A failed clearing write leaves uncertain references: the duplicate may still point to source secrets or to cloned secrets whose deletion already took effect. Its editor error therefore states that clearing failed and the record needs manual review, rather than claiming a successful rollback. The clearing update is attempted even if its XML matches the original copy snapshot, because a failing link write may already have changed the stored record. The secondary PSR-3 diagnostic writer is attempted once. If it throws, its failure is contained so that refusal and compensation can continue; the correlation reference identifies the attempt, not successful log delivery (see ADR-047: Contain failed hook diagnostic writes). Failures in other diagnostic collaborators and stores that throw after persistence remain separate audit work. Mandatory mutation-audit failures keep their existing refusal and rollback behavior.
A delete checks the delete permission of every vault field handled by
its hook before
removing the first secret, because the vault will not give a deleted secret
back: the delete is a soft delete, so the encrypted row is retained, but the
vault has no restore operation and the backend refuses TYPO3's undelete
command on tx_ (see
What the backend will not do to a secret record). If any field is denied, no secret is
removed and the record delete is cancelled. A field pointing at a secret that
no longer exists does not block the delete.
The FlexForm hook gathers unique, existing, non-shared references across its
columns and preflights them all before deleting the first secret. Duplicate
references are deleted once and references in another live record survive.
A reference is a value at an element the data structure configures with
renderType vault, including elements inside section containers; a
UUID in any other element is ordinary content and is never deleted. When a
value holds an identifier-shaped string but its data structure cannot be
resolved, the record deletion is cancelled.
Consumer availability is distinct from storage presence: disabled secrets
still participate in this cascade. The hook completes sharing exclusions
first, then uses authorized administrative metadata when the availability
lookup returns false. Only a missing-secret exception excludes an absent or
already-deleted reference; lookup failures cancel record deletion before any
secret preflight or deletion. Metadata lookup does not decrypt a secret value.
Plain TCA and FlexForm hooks preflight their own references separately; a
record mixing both kinds still lacks a shared preflight coordinator.
The preflight cannot cover a failure it is unable to predict — an audit write
that fails, a vault outage, a permission revoked between the check and the
delete. If one of those hits partway through, the loop stops rather than
enlarging the damage, the record is preserved, and the error names how many
secrets of preceding fields were already deleted and cannot be restored. That
count is the signal to re-enter those values; the record still exists, so
nothing else is lost. The FlexForm diagnostic instead warns that secrets
may already have been deleted: even the failing current delete may have
persisted before a Secret observer throws. It preserves the
owning record and stops further deletes, but cannot restore already-applied
secret deletions. The cancellation flag is set before diagnostics run.
See ADR-045: Preflight and contain FlexForm deletion.
Security considerations
Access control
Editing the record is necessary but not sufficient. Every vault field
mutation goes through the same two gates as any other vault operation: the
operation permission (secret. when the field is first filled,
secret. on a change, secret. when the record goes) and the
per-secret owner/group tiers. A backend user who may edit the record but holds
neither will have the mutation refused, and the refusal is audited — see
Operation permissions and Access control.
A refused hook operation presents a cause-independent message and a fresh correlation reference. Its secondary diagnostic attempt uses that same reference; caller-supplied context cannot replace it. Other record metadata is preserved. Diagnostic string values, including the cause class, have control bytes removed and a final UTF-8-safe limit of 200 bytes, even when configured replacement of malformed UTF-8 expands the input or introduces control bytes. This concerns the hook's PSR-3 diagnostic context, rather than the persisted mutation-audit record. A correlation reference alone is not proof of log delivery.
The reveal button requires explicit user action and is logged. Revealing also
asserts secret., which secret. does not imply.
On top of both gates, global (page 0) TSconfig can narrow field actions per
table and field. The service resolves a field setting before the table default,
then the global default; missing settings allow reveal, copy and edit, and
leave the field writable. It does not read the edited record's page TSconfig.
The same rules can be supplied as page. overrides in
backend user TSconfig. The ordinary widget and Hook calls pass no user and
read these overrides from the logged-in backend user. A caller that passes a
different user gets that user's overrides and backend-user conditions; see
ADR-049: Selected-user field TSconfig. For example:
vault.permissions {
default {
reveal = 1
copy = 1
edit = 1
}
tx_myext_settings {
default {
reveal = 0
}
api_key {
reveal = 1
copy = 0
edit = 1
readOnly = 0
}
}
}
These rules cannot grant an operation that the vault permission gates
withheld. reveal and copy control the widget. edit and read
also protect the plain TCA-field DataHandler write path; FlexForm writes do
not re-check this layer. Administrators bypass the restrictions: reveal, copy
and edit remain allowed, and read is always false. Non-admin results
are cached separately by table, field, action and user UID until
Vault clears that service cache.
All three public field-permission wrappers accept an optional backend-user
object. Supplying a distinct user evaluates that user's materialized
page.* overrides and native backend. conditions against the same
page-0 defaults. It leaves the global backend account, other Context aspects
and Core's ambient Page TSconfig runtime cache untouched. The ordinary widget
and Hook calls that omit the object retain Core's ambient resolution. This
argument selects a field policy; it does not log in as that user or grant a
Vault operation or secret ACL. See ADR-049: Selected-user field TSconfig.
Audit trail
All vault operations are logged:
- Secret creation.
- Secret reads (via reveal button).
- Secret updates.
- Secret deletion.
Review the audit log in the backend module under Admin Tools > Vault > Audit Log.
No plaintext in database
Only vault identifiers are stored in your extension's database tables. The actual secrets are encrypted with XChaCha20-Poly1305 — or AES-256-GCM when encryptionAlgorithm selects it — under a per-secret DEK that is itself wrapped by the master key.
Migration
To migrate existing plaintext credentials to vault storage:
- Add the
renderto your existing TCA field configuration.Type -
Run the migration command:
Migrate existing field to vaultvendor/bin/typo3 vault:migrate-field tx_myext_settings api_keyCopied!
This will:
- Read existing plaintext values.
- Store them securely in the vault.
- Update records with vault identifiers.
Attention
Always backup your database before running migrations.