ADR-009: Extension configuration secrets 

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 stored in the database ( sys_registry table in v12+) and loaded into $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'] at runtime.

Challenges 

  1. No PSR-14 events: TYPO3 provides no events for extension configuration save/load operations. The old afterExtensionConfigurationWrite signal was removed in v9.
  2. Memory persistence: Values loaded into $GLOBALS persist for the entire request lifecycle. Storing actual secrets there defeats vault's security model (immediate memory cleanup via sodium_memzero).
  3. 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 VaultHttpClient.

Two patterns are supported depending on use case:

Pattern B: Prefixed reference (optional) 

For mixed settings, explicit documentation, or migration from plaintext to vault:

Extension setting value
vault:my_translation_api_key
Copied!

Parsed with VaultReference helper:

Prefixed usage
<?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);
}
Copied!

Advantages:

  • Self-documenting in settings UI
  • Distinguishes vault refs from plain values
  • Explicit validation

Implementation 

Example: Translation service integration 

Extension settings template:

EXT:acme_translate/ext_conf_template.txt
# 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
Copied!

Service implementation:

EXT:acme_translate/Classes/Service/TranslationService.php
<?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'] ?? '';
    }
}
Copied!

Setup via backend:

  1. Create secret in vault:

    1. Go to Admin Tools > Vault > Secrets
    2. Click + Create new
    3. Enter identifier: acme_translate_api_key
    4. Paste your API key
    5. Click Save
  2. Configure extension:

    1. Go to Admin Tools > Settings > Extension Configuration
    2. Find acme_translate
    3. Enter API Key: acme_translate_api_key
    4. Click Save

Via CLI (alternative):

Store secret via CLI
./vendor/bin/typo3 vault:store acme_translate_api_key "your-actual-api-key"
Copied!

Why this is safe 

The extension setting stores only the identifier, never the secret:

What gets stored where
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]
Copied!

Even if someone accidentally enters the actual API key in extension settings:

  1. withAuthentication('sk_live_abc123...') tries vault lookup
  2. Vault returns "secret not found"
  3. Request fails safely (secret never sent)

Alternatives considered 

Store actual secrets in extension config 

Rejected because:

  • Secrets persist in $GLOBALS for 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 

Hypothetical
# type=user[Netresearch\NrVault\Configuration\VaultSecretField->render]
apiKey =
Copied!

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