HTTP Client
The IDK provides a multiplatform HttpClientFactory that creates pre-configured Ktor HttpClient instances with support for TLS, mutual TLS (mTLS), content negotiation, logging, and caching. The factory is injected via DI and selects the appropriate engine for the current platform automatically.
HttpClientFactory
The factory interface is simple: you pass options and get back a ready-to-use HttpClient:
interface HttpClientFactory {
fun createClient(options: HttpClientOptions): HttpClient
fun isSupportedOptions(options: HttpClientOptions): Boolean
fun getEngineTypesSupported(): List<HttpClientEngineType>
fun getEngineTypeDefault(): HttpClientEngineType
}
The factory is registered as a SessionScope singleton, so each user session gets its own factory instance with access to the session's key management services (needed for loading mTLS certificates).
Getting the Factory
In a Ktor route handler:
get("/proxy") {
val clientFactory = call.getSessionService<HttpClientFactory>()
val client = clientFactory.createClient(HttpClientOptions())
// use client...
}
In a DI-injected service:
@Inject
class MyApiClient(private val httpClientFactory: HttpClientFactory) {
private val client = httpClientFactory.createClient(
HttpClientOptions(enableContentNegotiation = true)
)
suspend fun fetchData(): MyResponse {
return client.get("https://api.example.com/data").body()
}
}
Client Options
HttpClientOptions configures every aspect of the created client:
data class HttpClientOptions(
val engine: HttpClientEngineType? = null,
val enableContentNegotiation: Boolean = false,
val contentNegotiationConfig: (ContentNegotiationConfig.() -> Unit)? = null,
val enableHttpCache: Boolean = false,
val httpCacheConfig: (HttpCache.Config.() -> Unit)? = null,
val enableLogging: Boolean = true,
val loggingConfig: LoggerConfig = LoggerConfig.Default,
val sslConfig: SslConfig = SslConfig(),
val defaultRequest: (DefaultRequest.DefaultRequestBuilder.() -> Unit)? = null,
val additionalConfig: (HttpClientConfig<*>.() -> Unit)? = null
)
| Option | Default | Description |
|---|---|---|
engine | Platform default | Override the HTTP engine (see below) |
enableContentNegotiation | false | Install JSON serialization (kotlinx.serialization) |
contentNegotiationConfig | - | Customize the JSON configuration |
enableHttpCache | false | Enable Ktor's HttpCache plugin |
enableLogging | false | Log HTTP requests and responses |
sslConfig | No TLS customization | TLS and mTLS configuration |
defaultRequest | - | Default headers, auth, base URL for every request |
additionalConfig | - | Arbitrary Ktor client configuration block |
Engine Types
| Engine | Platforms | Notes |
|---|---|---|
CIO | JVM, Native | Coroutine-based, HTTP/1.x only. RSA/DSS certificates only (no EC). |
OKHTTP | JVM, Android | Full TLS support including EC certificates. Default on JVM. |
DARWIN | iOS, macOS | Native NSURLSession. Uses iOS keychain for mTLS. |
JS | Browser, Node.js | Delegates TLS entirely to the platform. |
When no engine is specified, the factory picks the platform default: OKHTTP on JVM, DARWIN on Apple, JS on JavaScript.
Content Negotiation
Enable JSON serialization for request/response bodies:
val client = factory.createClient(HttpClientOptions(
enableContentNegotiation = true
))
// Bodies are automatically serialized/deserialized
val response: MyData = client.get("https://api.example.com/data").body()
client.post("https://api.example.com/data") {
contentType(ContentType.Application.Json)
setBody(MyRequest(name = "test"))
}
The default JSON configuration uses:
encodeDefaults = trueignoreUnknownKeys = trueprettyPrint = false
Override with contentNegotiationConfig if you need different settings.
Default Request Configuration
Set headers, authentication, or a base URL that apply to every request:
val client = factory.createClient(HttpClientOptions(
defaultRequest = {
header("Authorization", "Bearer $accessToken")
header("X-API-Key", apiKey)
header("X-Tenant-Id", tenantId)
}
))
Logging
Enable request/response logging for debugging:
val client = factory.createClient(HttpClientOptions(
enableLogging = true,
loggingConfig = LoggerConfig(
// configure log level, sanitization, etc.
)
))
TLS Configuration
The SslConfig class controls both client-side certificates (for mTLS) and server certificate validation (custom CAs):
data class SslConfig(
val client: ClientSslConfig = ClientSslConfig(),
val server: ServerSslConfig = ServerSslConfig()
)
Server Certificate Validation (Custom CAs)
By default the platform's built-in CA store is used. To add custom CA certificates (e.g., for internal PKI), configure the server section:
val client = factory.createClient(HttpClientOptions(
sslConfig = SslConfig(
server = ServerSslConfig(
ca = CaOpts(
includePlatformDefaults = true, // keep system CAs
additionalCAs = setOf(
KeystoreCertificateOpts(
certificateAlias = "internal-ca",
keyStoreId = "my-trust-store"
)
)
)
)
)
))
| Property | Default | Description |
|---|---|---|
includePlatformDefaults | true | Include the system CA trust store |
additionalCAs | empty | Additional CA certificates from your keystores |
The factory loads referenced keystores from the key management system (KMS) at all configuration levels (APP, TENANT, PRINCIPAL) and merges them into a CompositeTrustManager that accepts a server certificate if any of the trust managers trusts it.
Mutual TLS (mTLS)
mTLS lets the server verify the client's identity via a client certificate. The IDK supports per-host certificate routing, where different backend servers can require different client certificates, and the factory selects the right one automatically based on the target hostname.
Configuration
val client = factory.createClient(HttpClientOptions(
sslConfig = SslConfig(
client = ClientSslConfig(
// Default certificate for any host without a specific mapping
defaultCertificate = KeystoreCertificateOpts(
certificateAlias = "default-client-cert",
keyStoreId = "client-keystore"
),
// Per-host certificate overrides
perHostCertificate = mapOf(
"partner-a.example.com" to KeystoreCertificateOpts(
certificateAlias = "partner-a-cert",
keyStoreId = "partner-keystores"
),
"partner-b.example.com" to KeystoreCertificateOpts(
certificateAlias = "partner-b-cert",
keyStoreId = "partner-keystores"
)
)
)
)
))
| Property | Description |
|---|---|
defaultCertificate | Client certificate used when no per-host match exists |
perHostCertificate | Map of hostname to client certificate. During TLS handshake the factory checks the target hostname and selects the matching entry. |
Each KeystoreCertificateOpts references:
certificateAlias: the alias of the certificate (and its private key) within the keystorekeyStoreId: the identifier of the keystore in the IDK key management system
How Per-Host Routing Works
On JVM (OkHttp engine), the factory builds a HostBasedKeyManager that wraps the standard X509KeyManager. During the TLS handshake:
- The JVM calls
chooseClientAlias()on the key manager - The
HostBasedKeyManagerextracts the target hostname from the socket - It looks up the hostname in the
perHostCertificatemap - If found, it returns that certificate's alias; otherwise it falls back to
defaultCertificate - The underlying key manager provides the certificate chain and private key for the selected alias
Multiple keystores are supported transparently. The factory loads all referenced keystores and merges them into a CompositeKeyManager.
Checking mTLS Status
val sslConfig: SslConfig = // ...
// Check if any mTLS is configured
val hasMtls = sslConfig.client.isMtls()
// Check for a specific host
val needsCert = sslConfig.client.isMtls("partner-a.example.com")
Platform-Specific Behaviour
- JVM
- iOS / macOS
- JavaScript
The JVM factory (HttpClientFactoryJvmImpl) defaults to the OkHttp engine, which supports:
- EC, RSA, and DSS client certificates
- Per-host certificate routing via
HostBasedKeyManager - Custom CA trust stores via
CompositeTrustManager - Full SSLContext configuration
Keystores are loaded from the IDK key management system (KeyManagerService) across all scopes (APP, TENANT, PRINCIPAL) and from any registered KMS providers.
The CIO engine is also available but limited to RSA/DSS certificates (no EC support due to ktor-network-tls constraints).
The Apple factory (HttpClientFactoryIosImpl) uses the Darwin engine backed by NSURLSession. mTLS works via the iOS keychain:
- Certificate chains and private keys are loaded from the IDK key management system
- Private keys are stored in the iOS keychain (never exported as raw bytes)
SecIdentityRefobjects are created linking each certificate to its keychain key- During TLS challenges, the factory responds with the appropriate identity based on hostname
Custom CA validation has limited support on iOS due to Kotlin/Native interop constraints. The platform's default CA validation is used when custom CAs are configured.
The JS factory (HttpClientFactoryJsImpl) uses the JS engine, which delegates TLS entirely to the Node.js or browser runtime. mTLS configuration in HttpClientOptions is accepted but not applied; the platform handles TLS natively. A warning is logged if mTLS configuration is provided.
Combining Options
A typical production client configuration:
val client = factory.createClient(HttpClientOptions(
enableContentNegotiation = true,
enableLogging = true,
sslConfig = SslConfig(
client = ClientSslConfig(
defaultCertificate = KeystoreCertificateOpts(
certificateAlias = "my-service",
keyStoreId = "service-keystore"
)
),
server = ServerSslConfig(
ca = CaOpts(
includePlatformDefaults = true,
additionalCAs = setOf(
KeystoreCertificateOpts(
certificateAlias = "corp-ca",
keyStoreId = "trust-store"
)
)
)
)
),
defaultRequest = {
header("X-Tenant-Id", currentTenantId)
}
))
Config-Driven HTTP Clients
Instead of (or in addition to) building HttpClientOptions in code, you can drive HTTP client settings from config files. The HTTP client uses the module/service/command override mechanism, so you can set global defaults and then override specific settings for individual modules or commands.
Available properties
All properties live under the http.client config suffix:
# Engine selection (optional, platform default is used otherwise)
http.client.engine=OKHTTP
# Content negotiation (JSON serialization)
http.client.content.negotiation=true
# Base URL for all requests
http.client.base.url=https://api.example.com
# Timeouts (milliseconds)
http.client.timeout.connect.ms=30000
http.client.timeout.request.ms=60000
http.client.timeout.socket.ms=60000
# Retry
http.client.retry.max.retries=3
http.client.retry.delay.ms=1000
# Caching
http.client.cache.enabled=false
# Logging
http.client.logging.enabled=true
http.client.logging.min.level=INFO
http.client.logging.tag=HTTP
http.client.logging.output.format=TEXT
http.client.logging.include.timestamp=false
# URL validation (none, block.private)
http.client.url.validation.policy=block.private
# Default headers
http.client.headers.X-Tenant-Id=acme-corp
http.client.headers.X-API-Version=2
# TLS / mTLS
http.client.ssl.include.platform.cas=true
http.client.ssl.default.certificate.keystore.id=client-keystore
http.client.ssl.default.certificate.certificate.alias=default-cert
Or in YAML:
http:
client:
content:
negotiation: true
timeout:
connect:
ms: 30000
request:
ms: 60000
logging:
enabled: true
min:
level: INFO
base:
url: https://api.example.com
Per-module and per-command overrides
Different parts of your application often need different HTTP settings. The KMS might need shorter timeouts than the OID4VP module. A specific command might need verbose logging while everything else stays quiet.
Use the cmd.* prefix to override at the module, service, or command level:
# Global defaults
http.client.timeout.connect.ms=30000
http.client.logging.enabled=false
# KMS module: shorter timeouts for key operations
cmd.kms.default.default.http.client.timeout.connect.ms=5000
cmd.kms.default.default.http.client.timeout.request.ms=10000
# OID4VP module: different base URL
cmd.oid4vp.default.default.http.client.base.url=https://oid4vp.example.com
# Specific command: enable logging for debugging token exchange
cmd.oauth2.token.exchange.http.client.logging.enabled=true
cmd.oauth2.token.exchange.http.client.logging.min.level=DEBUG
The resolution merges from least-specific to most-specific. In the example above, the oauth2.token.exchange command gets logging.enabled=true and logging.min.level=DEBUG from its command-level override, but inherits the global timeout.connect.ms=30000 because no module or command override sets a different timeout.
Tenant-level overrides
Because config cascades through APP, TENANT, and PRINCIPAL scopes, different tenants can have different HTTP client settings:
http.client.base.url=https://partner-a-api.example.com
http.client.ssl.default.certificate.keystore.id=partner-a-keystore
http.client.ssl.default.certificate.certificate.alias=partner-a-cert
This gives partner-a its own base URL and mTLS certificate without affecting other tenants.
Using HttpClientConfigResolver
The HttpClientConfigResolver ties config resolution to HTTP client creation. It's injected in the session scope and resolves HttpClientProperties for a given command:
@Inject
class MyOAuth2Service(
private val httpClientFactory: HttpClientFactory,
private val configResolver: HttpClientConfigResolver,
) {
suspend fun exchangeToken(code: String): TokenResponse {
// Resolves config for "oauth2.token.exchange" command:
// global defaults, merged with module overrides, merged with command overrides
val props = configResolver.resolve("oauth2.token.exchange")
val client = httpClientFactory.createClient(props.toOptions())
return try {
client.post("/token") { /* ... */ }.body()
} finally {
client.close()
}
}
}
Or use the extension functions that combine resolution and client creation:
// Create a client with config resolved for a command
val client = httpClientFactory.createClient(configResolver, "oauth2.token.exchange")
// Create, use, and auto-close
val response = httpClientFactory.withClient(configResolver, "oauth2.token.exchange") { client ->
client.post("/token") { /* ... */ }.body<TokenResponse>()
}
// Global config (no command scoping)
val globalClient = httpClientFactory.createClientFromConfig(configResolver)
The resolved HttpClientProperties is converted to HttpClientOptions via toOptions(). Lambda-based options (like additionalConfig or contentNegotiationConfig) can't come from config files, so you pass them as programmatic overrides on top of the config-resolved base.
Extending with Additional Configuration
The additionalConfig block gives you full access to Ktor's HttpClientConfig DSL for anything not covered by the standard options:
val client = factory.createClient(HttpClientOptions(
additionalConfig = {
install(HttpTimeout) {
requestTimeoutMillis = 60_000
connectTimeoutMillis = 10_000
}
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 3)
exponentialDelay()
}
}
))