---
title: "Example: SaaS API keys in extension settings"
manual: "nr-vault"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:usage-extension-settings@1.0"
source: "Usage/ExtensionSettings.rst"
rendered: "2026-09-18T07:37:50+00:00"
---

# Example: SaaS API keys in extension settings {#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](https://docs.typo3.org/permalink/netresearch/nr-vault:the-challenge@1.0)
-   [Approaches](https://docs.typo3.org/permalink/netresearch/nr-vault:approaches@1.0)
-   [Comparison](https://docs.typo3.org/permalink/netresearch/nr-vault:comparison@1.0)

## The challenge {#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 {#approaches}

There are three approaches to secure extension settings with vault:

### Approach 1: Store vault identifier in settings (recommended) {#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:**

**EXT:my_deepl_extension/ext_conf_template.txt**

```text
# 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](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-009-extension-configuration-secrets@1.0) for the design rationale.

**Service implementation:**

**EXT:my_deepl_extension/Classes/Service/DeepLService.php**

```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\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.
        // DeepL uses the "Authorization: DeepL-Auth-Key <key>" scheme (not Bearer),
        // expressed via the Header placement + prefix option.
        $response = $this->vault->http()
            ->withAuthentication(
                $this->apiKeyRef->identifier,
                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'] ?? '';
    }
}

```

**Setup steps:**

1.  Store the actual DeepL API key in vault:

    **Via backend (recommended):**

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

    **Via CLI (alternative):**

    **Store API key via command line**

    ```bash
    ./vendor/bin/typo3 vault:store deepl_api_key --value="your-deepl-api-key-here"
    ```
1.  Configure extension setting with vault reference:

    **Via backend:**

    1.  Go to **Admin Tools > Settings > Extension Configuration**
    1.  Find your extension and expand it
    1.  Enter the vault reference: `vault:deepl_api_key`
    1.  Click **Save**
1.  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 {#approach-2-environment-variable-reference}

For containerized deployments, reference environment variables.

**Extension settings template:**

**EXT:my_deepl_extension/ext_conf_template.txt**

```text
# cat=api; type=string; label=DeepL API Key (env var): Environment variable name containing the API key
deeplApiKeyEnvVar = DEEPL_API_KEY
```

**Service implementation:**

**EXT:my_deepl_extension/Classes/Service/DeepLService.php**

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

```

> [!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 {#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
<?php

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

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:**

**EXT:my_deepl_extension/Classes/Domain/Repository/ConfigRepository.php**

```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;
    }
}

```

**DTO:**

**EXT:my_deepl_extension/Classes/Domain/Dto/DeepLConfig.php**

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

```

**Service using config record:**

**EXT:my_deepl_extension/Classes/Service/DeepLService.php**

```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 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 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 {#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.
