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_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
renderapproach doesn't work directlyType: 'vault Secret' - 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:
# cat=api; type=string; label=DeepL API Key (Vault Identifier): Enter the identifier of your stored secret
deeplApiKey =
The admin enters the identifier deepl_ (not the actual key).
The HTTP client resolves this identifier at use time. The optional
Vault 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:
<?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'] ?? '';
}
}
Register the service in the consuming extension's dependency-injection
configuration. TYPO3 13 and 14 provide the Request alias
through Core's HTTP request factory; the container injects it alongside the
vault service and extension configuration.
# 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: ~
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 --value="your-deepl-api-key-here"Copied! -
Configure the extension setting with the identifier:
Via backend:
- Go to Admin Tools > Settings > Extension Configuration
- Find your extension and expand it
- Enter the identifier:
deepl_api_ key - Click Save
- The service passes the identifier to the HTTP client, which 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 their 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
/*
* 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'] ?? '';
}
}
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
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'],
],
];
Repository to load configuration:
<?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;
}
}
DTO:
<?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'],
);
}
}
Missing configuration exception:
The service reports a missing active configuration with this application-specific exception before delegating any HTTP request.
<?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 {}
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: Deep scheme required by DeepL.
<?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'] ?? '';
}
}
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.