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

/*
 * 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);
}
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

/*
 * 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'] ?? '';
    }
}
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 --value="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