Example: SaaS API keys in extension settings 

Many TYPO3 extensions integrate with SaaS services (DeepL, Personio, Stepstone, Stripe, etc.) and store API keys in extension settings. This page shows how to secure these credentials with nr-vault.

The challenge 

Extension settings defined in ext_conf_template.txt are stored in TYPO3's local system configuration files. The filename depends on the TYPO3 version and project layout. These settings do not use TCA tables. This means:

  • The renderType: 'vaultSecret' approach doesn't work directly
  • API keys are stored as plaintext in the filesystem
  • Keys may end up in version control or backups

Approaches 

There are three approaches to secure extension settings with vault:

Approach 1: Store vault identifier in settings (recommended) 

Store the secret's identifier in extension settings, resolve at runtime.

Advantages:

  • Works with existing extension settings UI
  • No schema changes required
  • Secrets properly encrypted in vault
  • Uses the identifier directly with the vault HTTP client

Extension settings template:

EXT:my_deepl_extension/ext_conf_template.txt
# cat=api; type=string; label=DeepL API Key (Vault Identifier): Enter the identifier of your stored secret
deeplApiKey =
Copied!

The admin enters the identifier deepl_api_key (not the actual key).

The HTTP client resolves this identifier at use time. The optional VaultReference helper described in ADR-009 is not implemented; use its direct-identifier pattern. See ADR-009: Extension configuration secrets for the design rationale.

Service implementation:

EXT:my_deepl_extension/Classes/Service/DeepLService.php
<?php

/*
 * Copyright (c) 2025-2026 Netresearch DTT GmbH
 * SPDX-License-Identifier: GPL-2.0-or-later
 */

declare(strict_types=1);

namespace MyVendor\MyDeeplExtension\Service;

use Netresearch\NrVault\Http\SecretPlacement;
use Netresearch\NrVault\Service\VaultServiceInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use Psr\Http\Message\RequestFactoryInterface;

final class DeepLService
{
    private const API_URL = 'https://api-free.deepl.com/v2';

    private string $apiKeyIdentifier;

    public function __construct(
        private readonly VaultServiceInterface $vault,
        private readonly RequestFactoryInterface $requestFactory,
        ExtensionConfiguration $extensionConfiguration
    )
    {
        $config = $extensionConfiguration->get('my_deepl_extension');
        // The setting contains the identifier, never the credential.
        $this->apiKeyIdentifier = (string) ($config['deeplApiKey'] ?? '');
    }

    public function translate(string $text, string $targetLang): string
    {
        if ($this->apiKeyIdentifier === '') {
            throw new \RuntimeException(
                'DeepL API key not configured. Enter your secret identifier in extension settings.',
                1735900000
            );
        }

        $request = $this->requestFactory->createRequest('POST', self::API_URL . '/translate')
            ->withHeader('Content-Type', 'application/json')
            ->withBody(\GuzzleHttp\Psr7\Utils::streamFor(json_encode([
                'text' => [$text],
                'target_lang' => $targetLang,
            ])));

        // Secret resolved at use time, not config load time.
        // DeepL uses the "Authorization: DeepL-Auth-Key <key>" scheme (not Bearer),
        // expressed via the Header placement + prefix option.
        $response = $this->vault->http()
            ->withAuthentication(
                $this->apiKeyIdentifier,
                SecretPlacement::Header,
                ['headerName' => 'Authorization', 'prefix' => 'DeepL-Auth-Key '],
            )
            ->withReason('DeepL translation request')
            ->sendRequest($request);

        $data = json_decode($response->getBody()->getContents(), true);
        return $data['translations'][0]['text'] ?? '';
    }
}
Copied!

Register the service in the consuming extension's dependency-injection configuration. TYPO3 13 and 14 provide the RequestFactoryInterface alias through Core's HTTP request factory; the container injects it alongside the vault service and extension configuration.

EXT:my_deepl_extension/Configuration/Services.yaml
# SPDX-License-Identifier: GPL-2.0-or-later
# SPDX-FileCopyrightText: Netresearch DTT GmbH
services:
  _defaults:
    autowire: true
    autoconfigure: true
    public: false

  MyVendor\MyDeeplExtension\Service\DeepLService: ~
Copied!

Setup steps:

  1. Store the actual DeepL API key in vault:

    Via backend (recommended):

    1. Go to Admin Tools > Vault > Secrets
    2. Click + Create new
    3. Enter identifier: deepl_api_key
    4. Paste your DeepL API key into the secret field
    5. Optionally set owner and allowed groups
    6. Click Save

    Via CLI (alternative):

    Store API key via command line
    ./vendor/bin/typo3 vault:store deepl_api_key --value="your-deepl-api-key-here"
    Copied!
  2. Configure the extension setting with the identifier:

    Via backend:

    1. Go to Admin Tools > Settings > Extension Configuration
    2. Find your extension and expand it
    3. Enter the identifier: deepl_api_key
    4. Click Save
  3. The service passes the identifier to the HTTP client, which resolves the secret at use time.

Approach 2: Environment variable reference 

For containerized deployments, reference environment variables.

Extension settings template:

EXT:my_deepl_extension/ext_conf_template.txt
# cat=api; type=string; label=DeepL API Key (env var): Environment variable name containing the API key
deeplApiKeyEnvVar = DEEPL_API_KEY
Copied!

Service implementation:

EXT:my_deepl_extension/Classes/Service/DeepLService.php
<?php

/*
 * Copyright (c) 2025-2026 Netresearch DTT GmbH
 * SPDX-License-Identifier: GPL-2.0-or-later
 */

declare(strict_types=1);

namespace MyVendor\MyDeeplExtension\Service;

use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Http\RequestFactory;

final class DeepLService
{
    private const API_URL = 'https://api-free.deepl.com/v2';

    private string $apiKey;

    public function __construct(
        private readonly RequestFactory $requestFactory,
        ExtensionConfiguration $extensionConfiguration,
    ) {
        $config = $extensionConfiguration->get('my_deepl_extension');
        $envVar = (string) ($config['deeplApiKeyEnvVar'] ?? 'DEEPL_API_KEY');
        $this->apiKey = getenv($envVar) ?: '';
    }

    public function translate(string $text, string $targetLang): string
    {
        // Use TYPO3's RequestFactory directly with env-provided key
        $response = $this->requestFactory->request(
            self::API_URL . '/translate',
            'POST',
            [
                'headers' => [
                    'Authorization' => 'DeepL-Auth-Key ' . $this->apiKey,
                    'Content-Type' => 'application/json',
                ],
                'body' => json_encode([
                    'text' => [$text],
                    'target_lang' => $targetLang,
                ]),
            ]
        );

        $data = json_decode($response->getBody()->getContents(), true);
        return $data['translations'][0]['text'] ?? '';
    }
}
Copied!

Approach 3: Configuration record with TCA vault field 

For maximum security and a proper backend UI, create a configuration record.

TCA definition:

EXT:my_deepl_extension/Configuration/TCA/tx_mydeeplext_config.php
<?php

declare(strict_types=1);

/*
 * Copyright (c) 2025-2026 Netresearch DTT GmbH
 * SPDX-License-Identifier: GPL-2.0-or-later
 */
return [
    'ctrl' => [
        'title' => 'DeepL Configuration',
        'label' => 'name',
        'delete' => 'deleted',
        'rootLevel' => 1,
        'security' => [
            'ignorePageTypeRestriction' => true,
        ],
    ],
    'columns' => [
        'name' => [
            'label' => 'Configuration Name',
            'config' => [
                'type' => 'input',
                'default' => 'Default',
            ],
        ],
        'api_key' => [
            'label' => 'DeepL API Key',
            'config' => [
                'type' => 'input',
                'renderType' => 'vaultSecret',
            ],
        ],
        'api_url' => [
            'label' => 'API URL',
            'config' => [
                'type' => 'input',
                'default' => 'https://api-free.deepl.com/v2',
            ],
        ],
    ],
    'types' => [
        '0' => ['showitem' => 'name, api_key, api_url'],
    ],
];
Copied!

Repository to load configuration:

EXT:my_deepl_extension/Classes/Domain/Repository/ConfigRepository.php
<?php

/*
 * Copyright (c) 2025-2026 Netresearch DTT GmbH
 * SPDX-License-Identifier: GPL-2.0-or-later
 */

declare(strict_types=1);

namespace MyVendor\MyDeeplExtension\Domain\Repository;

use MyVendor\MyDeeplExtension\Domain\Dto\DeepLConfig;
use TYPO3\CMS\Core\Database\ConnectionPool;

final class ConfigRepository
{
    public function __construct(
        private readonly ConnectionPool $connectionPool,
    ) {}

    public function findDefault(): ?DeepLConfig
    {
        $row = $this->connectionPool
            ->getConnectionForTable('tx_mydeeplext_config')
            ->select(['*'], 'tx_mydeeplext_config', ['deleted' => 0])
            ->fetchAssociative();

        return $row ? DeepLConfig::fromDatabaseRow($row) : null;
    }
}
Copied!

DTO:

EXT:my_deepl_extension/Classes/Domain/Dto/DeepLConfig.php
<?php

/*
 * Copyright (c) 2025-2026 Netresearch DTT GmbH
 * SPDX-License-Identifier: GPL-2.0-or-later
 */

declare(strict_types=1);

namespace MyVendor\MyDeeplExtension\Domain\Dto;

final readonly class DeepLConfig
{
    public function __construct(
        public int $uid,
        public string $name,
        public string $apiKey,  // Vault UUID
        public string $apiUrl,
    ) {}

    public static function fromDatabaseRow(array $row): self
    {
        return new self(
            uid: (int) $row['uid'],
            name: (string) $row['name'],
            apiKey: (string) $row['api_key'],
            apiUrl: (string) $row['api_url'],
        );
    }
}
Copied!

Missing configuration exception:

The service reports a missing active configuration with this application-specific exception before delegating any HTTP request.

EXT:my_deepl_extension/Classes/Exception/DeepLNotConfiguredException.php
<?php

/* Copyright (c) 2026 Netresearch DTT GmbH
 * SPDX-License-Identifier: GPL-2.0-or-later */
declare(strict_types=1);

namespace MyVendor\MyDeeplExtension\Exception;

use RuntimeException;

final class DeepLNotConfiguredException extends RuntimeException {}
Copied!

Service using config record:

The TCA enables soft deletion because the repository selects records with deleted = 0. The service delegates the stored vault UUID to the HTTP client and selects the Authorization: DeepL-Auth-Key scheme required by DeepL.

EXT:my_deepl_extension/Classes/Service/DeepLService.php
<?php

/*
 * Copyright (c) 2025-2026 Netresearch DTT GmbH
 * SPDX-License-Identifier: GPL-2.0-or-later
 */

declare(strict_types=1);

namespace MyVendor\MyDeeplExtension\Service;

use GuzzleHttp\Psr7\Utils;
use MyVendor\MyDeeplExtension\Domain\Repository\ConfigRepository;
use MyVendor\MyDeeplExtension\Exception\DeepLNotConfiguredException;
use Netresearch\NrVault\Http\SecretPlacement;
use Netresearch\NrVault\Service\VaultServiceInterface;
use TYPO3\CMS\Core\Http\RequestFactory;

final readonly class DeepLService
{
    public function __construct(
        private VaultServiceInterface $vault,
        private RequestFactory $requestFactory,
        private ConfigRepository $configRepository,
    ) {}

    public function translate(string $text, string $targetLang): string
    {
        $config = $this->configRepository->findDefault();
        if ($config === null) {
            throw new DeepLNotConfiguredException('DeepL not configured', 1735900001);
        }

        $request = $this->requestFactory
            ->createRequest('POST', $config->apiUrl . '/translate')
            ->withHeader('Content-Type', 'application/json')
            ->withBody(Utils::streamFor(json_encode([
                'text' => [$text],
                'target_lang' => $targetLang,
            ])));

        $response = $this->vault
            ->http()
            ->withAuthentication(
                $config->apiKey,
                SecretPlacement::Header,
                ['headerName' => 'Authorization', 'prefix' => 'DeepL-Auth-Key '],
            )
            ->withReason('DeepL translation: ' . $targetLang)
            ->sendRequest($request);

        $data = json_decode($response->getBody()->getContents(), true);

        return $data['translations'][0]['text'] ?? '';
    }
}
Copied!

Advantages of Approach 3:

  • Full vault UI with masked input and reveal — plus copy, outside the hardened profile, which disables it deliberately
  • Proper access control (owner, groups)
  • Audit logging of configuration access
  • Multiple configurations possible (e.g., per site)

Comparison 

Aspect Approach 1 (Vault ID) Approach 2 (Env var) Approach 3 (TCA record)
Setup complexity Low Low Medium
Security High Medium High
Memory safety Yes (sodium_memzero) No Yes (sodium_memzero)
Audit trail Yes No Yes
Backend UI Text field Text field Vault secret field
Multi-config Manual Manual Native

Recommendation: Use Approach 1 for simple single-key integrations, Approach 3 for complex integrations requiring multiple configurations or strict access control.