---
title: "ADR-009: Extension configuration secrets"
manual: "nr-vault"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:adr-009-extension-configuration-secrets@1.0"
source: "Developer/Adr/ADR-009-ExtensionConfigurationSecrets.rst"
rendered: "2026-09-18T07:37:50+00:00"
---

# ADR-009: Extension configuration secrets {#adr-009-extension-configuration-secrets-1}

**Table of contents**

-   [Status](https://docs.typo3.org/permalink/netresearch/nr-vault:status@1.0)
-   [Date](https://docs.typo3.org/permalink/netresearch/nr-vault:date@1.0)
-   [Context](https://docs.typo3.org/permalink/netresearch/nr-vault:context@1.0)
-   [Decision](https://docs.typo3.org/permalink/netresearch/nr-vault:decision@1.0)
-   [Implementation](https://docs.typo3.org/permalink/netresearch/nr-vault:implementation@1.0)
-   [Alternatives considered](https://docs.typo3.org/permalink/netresearch/nr-vault:alternatives-considered@1.0)
-   [Consequences](https://docs.typo3.org/permalink/netresearch/nr-vault:consequences@1.0)
-   [References](https://docs.typo3.org/permalink/netresearch/nr-vault:references@1.0)

## Status {#status}

Accepted

## Date {#date}

2026-01-04

## Context {#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 {#challenges}

1.  **No PSR-14 events**: TYPO3 provides no events for extension configuration
    save/load operations. The old `afterExtensionConfigurationWrite` signal
    was removed in v9.
1.  **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`).
1.  **No custom field types**: While `type=user[...]` allows custom rendering,
    there's no hook into the save/load lifecycle to intercept values.

## Decision {#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 A: Direct identifier (recommended) {#pattern-a-direct-identifier-recommended}

For settings that are always vault references:

**Extension setting value**

```text
my_translation_api_key
```

Used directly with `withAuthentication()`:

**Direct usage**

```php
<?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) {#pattern-b-prefixed-reference-optional}

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

**Extension setting value**

```text
vault:my_translation_api_key
```

Parsed with `VaultReference` helper:

**Prefixed usage**

```php
<?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 {#implementation}

### Example: Translation service integration {#example-translation-service-integration}

**Extension settings template:**

**EXT:acme_translate/ext_conf_template.txt**

```text
# 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:**

**EXT:acme_translate/Classes/Service/TranslationService.php**

```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'] ?? '';
    }
}

```

**Setup via backend:**

1.  Create secret in vault:

    1.  Go to **Admin Tools > Vault > Secrets**
    1.  Click **\+ Create new**
    1.  Enter identifier: `acme_translate_api_key`
    1.  Paste your API key
    1.  Click **Save**
1.  Configure extension:

    1.  Go to **Admin Tools > Settings > Extension Configuration**
    1.  Find **acme_translate**
    1.  Enter API Key: `acme_translate_api_key`
    1.  Click **Save**

**Via CLI (alternative):**

**Store secret via CLI**

```bash
./vendor/bin/typo3 vault:store acme_translate_api_key --value="your-actual-api-key"
```

### Why this is safe {#why-this-is-safe}

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

**What gets stored where**

```text
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:

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

## Alternatives considered {#alternatives-considered}

### Store actual secrets in extension config {#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 {#custom-user-field-type-with-vault-ui}

**Hypothetical**

```text
# 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 {#consequences}

### Positive {#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 {#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 {#references}

-   [Example: SaaS API keys in extension settings](https://docs.typo3.org/permalink/netresearch/nr-vault:usage-extension-settings@1.0) \- Usage documentation
-   [ext_conf_template.txt](https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/ExtensionArchitecture/FileStructure/ExtConfTemplate.html) \- TYPO3 documentation
