ADR-008: HTTP client
Table of contents
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:
$apiKey = $vault->retrieve('stripe_api_key');
$client->request('POST', '/charges', [
'headers' => ['Authorization' => 'Bearer ' . $apiKey],
]);
// $apiKey remains in memory, possibly logged
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:
- Standards compliance: Works with any PSR-18 compatible code
- Immutability: Each
with*call returns new instance, preventing state issues - Late binding: Secrets retrieved only when request is sent
- Memory safety:
sodium_memzero()clears secrets after injection - Audit integration: Logs API calls with secret identifiers (not values)
Implementation
Interface design
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;
}
SecretPlacement enum
Type-safe authentication placement options:
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
}
Fluent API usage
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'));
Immutable implementation
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;
}
}
Memory-safe secret injection
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);
}
}
}
OAuth 2.0 support
$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);
The
OAuth handles:
- Token caching (in-memory)
- Automatic refresh before expiry
- Secure credential handling
Secure client factory
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,
]);
}
}
Audit logging
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(),
),
);
}
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: falseprevents 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/finallyto ensure cleanup even on exceptions - Avoid storing configured requests in variables
- Document memory safety requirements