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_, managed via
Admin Tools > Settings > Extension Configuration).
These settings are stored in the database (
sys_ table in v12+)
and loaded into
$GLOBALS at runtime.
Challenges
- No PSR-14 events: TYPO3 provides no events for extension configuration
save/load operations. The old
afterExtensionConfigurationWritesignal was removed in v9. - 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=user[...]allows 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.
Two patterns are supported depending on use case:
Pattern A: Direct identifier (recommended)
For settings that are always vault references:
my_translation_api_key
Used directly with
with:
<?php
// 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)
For mixed settings, explicit documentation, or migration from plaintext to vault:
vault:my_translation_api_key
Parsed with
Vault helper:
<?php
// 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:
# 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
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 "your-actual-api-key"
Why this is safe
The extension setting stores only the identifier, never the secret:
sys_registry (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_memzero()cleanup possible - Secrets visible in database (sys_registry)
- 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