Skip to main content
Version: v0.25.0 (Latest)

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
)
OptionDefaultDescription
enginePlatform defaultOverride the HTTP engine (see below)
enableContentNegotiationfalseInstall JSON serialization (kotlinx.serialization)
contentNegotiationConfig-Customize the JSON configuration
enableHttpCachefalseEnable Ktor's HttpCache plugin
enableLoggingfalseLog HTTP requests and responses
sslConfigNo TLS customizationTLS and mTLS configuration
defaultRequest-Default headers, auth, base URL for every request
additionalConfig-Arbitrary Ktor client configuration block

Engine Types

EnginePlatformsNotes
CIOJVM, NativeCoroutine-based, HTTP/1.x only. RSA/DSS certificates only (no EC).
OKHTTPJVM, AndroidFull TLS support including EC certificates. Default on JVM.
DARWINiOS, macOSNative NSURLSession. Uses iOS keychain for mTLS.
JSBrowser, Node.jsDelegates 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 = true
  • ignoreUnknownKeys = true
  • prettyPrint = 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"
)
)
)
)
)
))
PropertyDefaultDescription
includePlatformDefaultstrueInclude the system CA trust store
additionalCAsemptyAdditional 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"
)
)
)
)
))
PropertyDescription
defaultCertificateClient certificate used when no per-host match exists
perHostCertificateMap 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 keystore
  • keyStoreId: 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:

  1. The JVM calls chooseClientAlias() on the key manager
  2. The HostBasedKeyManager extracts the target hostname from the socket
  3. It looks up the hostname in the perHostCertificate map
  4. If found, it returns that certificate's alias; otherwise it falls back to defaultCertificate
  5. 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

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).

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:

application.yml
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:

config/tenant/partner-a/tenant.properties
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()
}
}
))