ADR-009: Extension configuration secrets
Table of contents
Status
Accepted
Date
2026-01-04
Context
TYPO3 extensions commonly store API keys and credentials in extension settings
(defined in ext_conf_template.txt, managed via
Admin Tools > Settings > Extension Configuration).
These settings are persisted in TYPO3's local system configuration and loaded
into
$GLOBALS at runtime.
Note
Current storage on the supported TYPO3 13 and 14 majors: Core's
Extension writes the EXTENSIONS/<extension>
path through Configuration.
The settings belong to the deployment's configuration files and backups,
not sys_. The configuration filename depends on the TYPO3
version and project layout; consuming services should use
Extension instead of reading a file themselves.
Challenges
- No PSR-14 events: TYPO3 provides no events for extension configuration
save/load operations. The old
aftersignal was removed in v9.Extension Configuration Write - Memory persistence: Values loaded into
$GLOBALSpersist for the entire request lifecycle. Storing actual secrets there defeats vault's security model (immediate memory cleanup viasodium_).memzero - No custom field types: While
type=userallows custom rendering, there's no hook into the save/load lifecycle to intercept values.[...]
Decision
Store vault identifiers (not secrets) in extension settings. The identifier
is resolved to the actual secret only at use time via
Vault.
This decision records two patterns. Pattern A is implemented; Pattern B remains a proposed helper API, as described in its implementation-status note.
Pattern A: Direct identifier (recommended)
For settings that are always vault references:
my_translation_api_key
Used directly with
with:
<?php
/*
* Copyright (c) 2025-2026 Netresearch DTT GmbH
* SPDX-License-Identifier: GPL-2.0-or-later
*/
// Pattern A: Direct identifier usage
// Extension setting value: my_translation_api_key
$vault->http()
->withAuthentication($config['apiKey'], SecretPlacement::Bearer)
->sendRequest($request);
Advantages:
- Simple, no parsing needed
- Safe failure mode (vault lookup fails if wrong value)
- Works directly with VaultHttpClient
Pattern B: Prefixed reference (optional)
Note
Implementation status (2026-10-10): the Vault helper described
below has not been implemented. Pattern A is the supported integration.
This section records the accepted design, not an available parsing API.
For mixed settings, explicit documentation, or migration from plaintext to vault:
vault:my_translation_api_key
Parsed with
Vault helper:
<?php
/*
* Copyright (c) 2025-2026 Netresearch DTT GmbH
* SPDX-License-Identifier: GPL-2.0-or-later
*/
// Pattern B: Prefixed reference usage
// Extension setting value: vault:my_translation_api_key
$ref = VaultReference::tryParse($config['apiKey']);
if ($ref !== null) {
$vault->http()
->withAuthentication($ref->identifier, SecretPlacement::Bearer)
->sendRequest($request);
}
Advantages:
- Self-documenting in settings UI
- Distinguishes vault refs from plain values
- Explicit validation
Implementation
Example: Translation service integration
Extension settings template:
# SPDX-License-Identifier: GPL-2.0-or-later
# SPDX-FileCopyrightText: Netresearch DTT GmbH
# cat=api; type=string; label=API Key (Vault Identifier): Enter your vault secret identifier
apiKey =
# cat=api; type=string; label=API Endpoint
apiEndpoint = https://api.translate.example.com/v1
Service implementation:
<?php
/*
* Copyright (c) 2025-2026 Netresearch DTT GmbH
* SPDX-License-Identifier: GPL-2.0-or-later
*/
declare(strict_types=1);
namespace Acme\AcmeTranslate\Service;
use Netresearch\NrVault\Http\SecretPlacement;
use Netresearch\NrVault\Service\VaultServiceInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Http\RequestFactory;
final class TranslationService
{
private string $apiKey;
private string $apiEndpoint;
public function __construct(
private readonly VaultServiceInterface $vault,
private readonly RequestFactory $requestFactory,
ExtensionConfiguration $extensionConfiguration,
) {
$config = $extensionConfiguration->get('acme_translate');
$this->apiKey = (string) ($config['apiKey'] ?? '');
$this->apiEndpoint = (string) ($config['apiEndpoint'] ?? '');
}
public function translate(string $text, string $targetLang): string
{
if ($this->apiKey === '') {
throw new \RuntimeException(
'Translation API key not configured in extension settings.',
1735990000
);
}
$request = $this->requestFactory
->createRequest('POST', $this->apiEndpoint . '/translate')
->withHeader('Content-Type', 'application/json')
->withBody(\GuzzleHttp\Psr7\Utils::streamFor(json_encode([
'text' => $text,
'target' => $targetLang,
])));
// $this->apiKey contains vault identifier, resolved at request time
$response = $this->vault->http()
->withAuthentication($this->apiKey, SecretPlacement::Bearer)
->withReason('Translation request: ' . $targetLang)
->sendRequest($request);
$data = json_decode($response->getBody()->getContents(), true);
return $data['translation'] ?? '';
}
}
Setup via backend:
-
Create secret in vault:
- Go to Admin Tools > Vault > Secrets
- Click + Create new
- Enter identifier:
acme_translate_ api_ key - Paste your API key
- Click Save
-
Configure extension:
- Go to Admin Tools > Settings > Extension Configuration
- Find acme_translate
- Enter API Key:
acme_translate_ api_ key - Click Save
Via CLI (alternative):
./vendor/bin/typo3 vault:store acme_translate_api_key --value="your-actual-api-key"
Why this is safe
The extension setting stores only the identifier, never the secret:
TYPO3 local system configuration (extension config):
apiKey = "acme_translate_api_key" ← Just the identifier
tx_nrvault_secret (vault):
identifier = "acme_translate_api_key"
encrypted_value = [AES-256-GCM encrypted actual key]
Even if someone accidentally enters the actual API key in extension settings:
withtries vault lookupAuthentication ('sk_ live_ abc123...') - Vault returns "secret not found"
- Request fails safely (secret never sent)
Alternatives considered
Store actual secrets in extension config
Rejected because:
- Secrets persist in
$GLOBALSfor entire request - No
sodium_cleanup possiblememzero () - Secrets visible in deployment configuration files
- May leak to logs, backups, version control
Custom user field type with vault UI
# type=user[Netresearch\NrVault\Configuration\VaultSecretField->render]
apiKey =
Rejected because:
- No save/load lifecycle hooks in TYPO3
- Would need to store secret in config (defeats purpose)
- Complex JavaScript for vault API interaction
Consequences
Positive
- Memory safety preserved: Secrets resolved only at use time
- Simple pattern: Direct identifier works with VaultHttpClient
- Safe failure: Wrong values cause "not found", not exposure
- No core changes: Works with standard extension configuration
- Backend-friendly: Admins manage via TYPO3 backend, no CLI needed
Negative
- Two-step setup: Create secret in vault, then reference in settings
- No UI validation: Extension settings show plain text field
- Convention-based: Developers must document which fields are vault refs
References
- Example: SaaS API keys in extension settings - Usage documentation
- ext_conf_template.txt - TYPO3 documentation