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 LocalConfiguration.php - not in 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 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

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

return [
    'ctrl' => [
        'title' => 'DeepL Configuration',
        'label' => 'name',
        '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

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

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!

Service using config record:

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

declare(strict_types=1);

namespace MyVendor\MyDeeplExtension\Service;

use MyVendor\MyDeeplExtension\Domain\Repository\ConfigRepository;
use Netresearch\NrVault\Http\SecretPlacement;
use Netresearch\NrVault\Service\VaultServiceInterface;
use TYPO3\CMS\Core\Http\RequestFactory;

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

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

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

        $response = $this->vault->http()
            ->withAuthentication($config->apiKey, SecretPlacement::Bearer)
            ->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, reveal, copy buttons
  • 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.