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.
Table of contents
The challenge
Extension settings defined in ext_ are stored in
Local - 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 1: Store vault identifier in settings (recommended)
Store a vault reference in extension settings, resolve at runtime.
Advantages:
- Works with existing extension settings UI
- No schema changes required
- Secrets properly encrypted in vault
- Self-documenting
vault:prefix
Extension settings template:
# cat=api; type=string; label=DeepL API Key (Vault Reference): Enter vault:your-secret-id
deeplApiKey = vault:
The admin enters a vault reference like vault:deepl_api_key (not the actual key).
The vault: prefix makes it clear this is a vault reference and enables
validation. See ADR-009: Extension configuration secrets for the design rationale.
Service implementation:
<?php
declare(strict_types=1);
namespace MyVendor\MyDeeplExtension\Service;
use Netresearch\NrVault\Configuration\VaultReference;
use Netresearch\NrVault\Http\SecretPlacement;
use Netresearch\NrVault\Service\VaultServiceInterface;
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 ?VaultReference $apiKeyRef = null;
public function __construct(
private readonly VaultServiceInterface $vault,
private readonly RequestFactory $requestFactory,
ExtensionConfiguration $extensionConfiguration,
) {
$config = $extensionConfiguration->get('my_deepl_extension');
$setting = (string) ($config['deeplApiKey'] ?? '');
// Parse vault reference (validates format, extracts identifier)
$this->apiKeyRef = VaultReference::tryParse($setting);
}
public function translate(string $text, string $targetLang): string
{
if ($this->apiKeyRef === null) {
throw new \RuntimeException(
'DeepL API key not configured. Enter vault:your-secret-id 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
$response = $this->vault->http()
->withAuthentication($this->apiKeyRef->identifier, SecretPlacement::Bearer)
->withReason('DeepL translation request')
->sendRequest($request);
$data = json_decode($response->getBody()->getContents(), true);
return $data['translations'][0]['text'] ?? '';
}
}
Setup steps:
-
Store the actual DeepL API key in vault:
Via backend (recommended):
- Go to Admin Tools > Vault > Secrets
- Click + Create new
- Enter identifier:
deepl_api_key - Paste your DeepL API key into the secret field
- Optionally set owner and allowed groups
- Click Save
Via CLI (alternative):
Store API key via command line./vendor/bin/typo3 vault:store deepl_api_key "your-deepl-api-key-here"Copied! -
Configure extension setting with vault reference:
Via backend:
- Go to Admin Tools > Settings > Extension Configuration
- Find your extension and expand it
- Enter the vault reference:
vault:deepl_api_key - Click Save
- The service parses the
vault:prefix and resolves the secret at use time.
Tip
No CLI or file access required. Admins can manage everything through the TYPO3 backend:
- Create secrets in Admin Tools > Vault
- Reference them in Extension Configuration with
vault:identifier
Approach 2: Environment variable reference
For containerized deployments, reference environment variables.
Extension settings template:
# cat=api; type=string; label=DeepL API Key (env var): Environment variable name containing the API key
deeplApiKeyEnvVar = DEEPL_API_KEY
Service implementation:
<?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'] ?? '';
}
}
Warning
This approach stores the API key in memory for the request lifetime. No automatic memory cleanup. Consider Approach 1 for sensitive keys.
Approach 3: Configuration record with TCA vault field
For maximum security and a proper backend UI, create a configuration record.
TCA definition:
<?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'],
],
];
Repository to load configuration:
<?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;
}
}
DTO:
<?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'],
);
}
}
Service using config record:
<?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'] ?? '';
}
}
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.