ADR-008: HTTP client 

Status 

Accepted

Date 

2026-01-03

Context 

Applications often need to make authenticated HTTP requests to external APIs using secrets stored in the vault. The typical pattern exposes secrets to application code:

Typical insecure pattern
$apiKey = $vault->retrieve('stripe_api_key');
$client->request('POST', '/charges', [
    'headers' => ['Authorization' => 'Bearer ' . $apiKey],
]);
// $apiKey remains in memory, possibly logged
Copied!

This approach:

  • Exposes secrets to application code
  • Risks logging secrets in debug output
  • Requires manual memory cleanup
  • Duplicates authentication logic across services

Problem statement 

How can applications make authenticated HTTP requests using vault secrets without exposing the secret values to application code?

Decision drivers 

  • Secret isolation: Application code should never see raw secrets
  • Memory safety: Secrets cleared from memory immediately after use
  • Standards compliance: Use PSR-18 for HTTP client interoperability
  • Flexibility: Support various authentication methods
  • Auditability: Log API calls without exposing credentials
  • Simplicity: Fluent API for common use cases

Considered options 

Option 1: Helper methods returning configured clients 

Factory methods that return pre-configured HTTP clients.

Pros:

  • Simple API

Cons:

  • Secrets exposed during client creation
  • Limited flexibility
  • Hard to audit

Option 2: Request middleware/interceptor 

Middleware that injects credentials into requests.

Pros:

  • Transparent injection

Cons:

  • Framework-specific (Guzzle middleware vs PSR-18)
  • Complex configuration

Option 3: PSR-18 wrapper with fluent authentication API 

Immutable wrapper implementing PSR-18 with authentication configuration.

Pros:

  • Standards-compliant (PSR-18)
  • Immutable (thread-safe, predictable)
  • Fluent API for configuration
  • Secret injection at request time

Cons:

  • Wrapper overhead
  • Must implement all PSR-18 methods

Decision 

We chose PSR-18 wrapper with fluent authentication API because:

  1. Standards compliance: Works with any PSR-18 compatible code
  2. Immutability: Each with* call returns new instance, preventing state issues
  3. Late binding: Secrets retrieved only when request is sent
  4. Memory safety: sodium_memzero() clears secrets after injection
  5. Audit integration: Logs API calls with secret identifiers (not values)

Implementation 

Interface design 

Classes/Http/VaultHttpClientInterface.php
interface VaultHttpClientInterface extends ClientInterface
{
    public function withAuthentication(
        string $secretIdentifier,
        SecretPlacement $placement = SecretPlacement::Bearer,
        array $options = [],
    ): static;

    public function withOAuth(OAuthConfig $config, string $reason = ''): static;

    public function withReason(string $reason): static;
}
Copied!

SecretPlacement enum 

Type-safe authentication placement options:

Classes/Http/SecretPlacement.php
enum SecretPlacement: string
{
    case Bearer = 'bearer';         // Authorization: Bearer {secret}
    case BasicAuth = 'basic';       // Authorization: Basic {base64}
    case Header = 'header';         // Custom header
    case QueryParam = 'query';      // URL query parameter
    case BodyField = 'body_field';  // Request body field
    case OAuth2 = 'oauth2';         // OAuth 2.0 with token refresh
    case ApiKey = 'api_key';        // X-API-Key header
}
Copied!

Fluent API usage 

Using VaultHttpClient
use GuzzleHttp\Psr7\Request;
use Netresearch\NrVault\Http\SecretPlacement;

// Bearer authentication
$response = $this->httpClient
    ->withAuthentication('stripe_api_key', SecretPlacement::Bearer)
    ->sendRequest(new Request('POST', 'https://api.stripe.com/v1/charges'));

// Custom header
$response = $this->httpClient
    ->withAuthentication('api_token', SecretPlacement::Header, [
        'headerName' => 'X-API-Key',
    ])
    ->sendRequest(new Request('GET', 'https://api.example.com/data'));

// Basic authentication with two secrets
$response = $this->httpClient
    ->withAuthentication('service_password', SecretPlacement::BasicAuth, [
        'usernameSecret' => 'service_username',
        'reason' => 'Fetching secure data',
    ])
    ->sendRequest(new Request('GET', 'https://api.example.com/secure'));
Copied!

Immutable implementation 

Classes/Http/VaultHttpClient.php
final readonly class VaultHttpClient implements VaultHttpClientInterface
{
    public function __construct(
        private VaultServiceInterface $vaultService,
        private AuditLogServiceInterface $auditLogService,
        private ?ClientInterface $innerClient = null,
        private ?string $secretIdentifier = null,
        private ?SecretPlacement $placement = null,
        // ... other configuration
    ) {}

    public function withAuthentication(
        string $secretIdentifier,
        SecretPlacement $placement = SecretPlacement::Bearer,
        array $options = [],
    ): static {
        // Return NEW instance with updated configuration
        return new self(
            $this->vaultService,
            $this->auditLogService,
            $this->innerClient,
            $secretIdentifier,
            $placement,
            // ... merge options
        );
    }

    public function sendRequest(RequestInterface $request): ResponseInterface
    {
        // Inject authentication into request
        $request = $this->injectAuthentication($request);

        // Send request
        $response = $this->getInnerClient()->sendRequest($request);

        // Audit log (secret identifier, not value)
        $this->logHttpCall($request, $response);

        return $response;
    }
}
Copied!

Memory-safe secret injection 

Secure injection with immediate cleanup
private function injectBearer(RequestInterface $request): RequestInterface
{
    $secret = $this->vaultService->retrieve($this->secretIdentifier);

    try {
        return $request->withHeader('Authorization', 'Bearer ' . $secret);
    } finally {
        sodium_memzero($secret);  // Clear from memory immediately
    }
}

private function injectBasicAuth(RequestInterface $request): RequestInterface
{
    $password = $this->vaultService->retrieve($this->secretIdentifier);
    $username = $this->usernameSecretIdentifier
        ? $this->vaultService->retrieve($this->usernameSecretIdentifier)
        : '';

    try {
        $credentials = base64_encode($username . ':' . $password);
        return $request->withHeader('Authorization', 'Basic ' . $credentials);
    } finally {
        sodium_memzero($password);
        if ($username !== '') {
            sodium_memzero($username);
        }
    }
}
Copied!

OAuth 2.0 support 

OAuth configuration
$config = OAuthConfig::clientCredentials(
    tokenUrl: 'https://oauth.example.com/token',
    clientIdSecret: 'oauth_client_id',
    clientSecretSecret: 'oauth_client_secret',
    scopes: ['read', 'write'],
);

$response = $this->httpClient
    ->withOAuth($config, 'API access')
    ->sendRequest($request);
Copied!

The OAuthTokenManager handles:

  • Token caching (in-memory)
  • Automatic refresh before expiry
  • Secure credential handling

Secure client factory 

Classes/Http/SecureHttpClientFactory.php
final class SecureHttpClientFactory
{
    public function create(): ClientInterface
    {
        return new Client([
            'debug' => false,  // Never log request/response bodies
            'http_errors' => false,  // Handle errors in vault client
            // Respect TYPO3 HTTP settings
            'proxy' => $GLOBALS['TYPO3_CONF_VARS']['HTTP']['proxy'] ?? null,
            'verify' => $GLOBALS['TYPO3_CONF_VARS']['HTTP']['verify'] ?? true,
            'timeout' => $GLOBALS['TYPO3_CONF_VARS']['HTTP']['timeout'] ?? 30,
        ]);
    }
}
Copied!

Audit logging 

Logging without exposing secrets
private function logHttpCall(RequestInterface $request, ResponseInterface $response): void
{
    $this->auditLogService->log(
        $this->secretIdentifier,          // Which secret was used
        'http_call',
        $response->getStatusCode() < 400,  // Success flag
        null,
        $this->reason,
        context: HttpCallContext::fromRequest(
            $request->getMethod(),
            (string) $request->getUri(),
            $response->getStatusCode(),
        ),
    );
}
Copied!

Consequences 

Positive 

  • Secret isolation: Application code never sees raw secret values
  • Memory safety: sodium_memzero() clears secrets immediately
  • Standards compliance: PSR-18 compatible, works with any framework
  • Immutable design: Thread-safe, predictable behavior
  • Audit trail: API calls logged with context, not credentials
  • TYPO3 integration: Respects proxy, SSL, timeout settings
  • No debug leaks: debug: false prevents request/response logging

Negative 

  • Wrapper overhead: Additional object creation per request
  • PSR-18 limitation: Async requests not supported by PSR-18
  • Memory pressure: Brief window where secret exists in memory

Risks 

  • Exception handlers might capture request objects with injected secrets
  • Memory dumps could expose secrets during injection window

Mitigation 

  • Use try/finally to ensure cleanup even on exceptions
  • Avoid storing configured requests in variables
  • Document memory safety requirements

References