HSTS in Ktor is simple once you know where to put it, and easy to get wrong if you treat it like just another header.

Strict-Transport-Security tells browsers: “for this domain, use HTTPS only for a while.” After a browser sees it over a valid HTTPS response, future HTTP requests get upgraded to HTTPS before they ever leave the browser. That blocks protocol downgrade attacks and strips out a whole class of sloppy redirect problems.

The catch: HSTS is sticky. If you ship a bad value to production, browsers remember it.

This guide is the practical version: what to set, where to set it in Ktor, when to use preload, and how to avoid bricking your own subdomains.

The header

A typical production header looks like this:

Strict-Transport-Security: max-age=31536000; includeSubDomains

With preload:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

Directives:

  • max-age=31536000 — cache policy for 1 year
  • includeSubDomains — apply to all subdomains too
  • preload — asks browsers to include your domain in preload lists

My default advice:

  • Start with max-age=300 in staging or a canary release
  • Move to max-age=31536000 in production once you’re sure HTTPS works everywhere
  • Only add includeSubDomains if every subdomain is HTTPS-clean
  • Only add preload if you really mean it

Ktor: the easiest way

If you’re using Ktor server, add HSTS in the response pipeline for HTTPS requests.

Ktor plugin-style setup

import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.http.*
import io.ktor.server.routing.*
import io.ktor.server.plugins.*

fun Application.module() {
    install(DefaultHeaders) {
        header(
            HttpHeaders.StrictTransportSecurity,
            "max-age=31536000; includeSubDomains"
        )
    }

    routing {
        get("/") {
            call.respondText("Hello, HTTPS")
        }
    }
}

This works, but I don’t love blindly setting it everywhere unless I know the app is only served behind HTTPS. If your Ktor app sometimes runs locally over plain HTTP, this can cause confusion during testing.

A safer production-aware version checks whether the original request is HTTPS.

Set HSTS only for HTTPS requests

Browsers ignore HSTS sent over HTTP, but I still prefer being explicit.

import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.http.*
import io.ktor.server.routing.*
import io.ktor.util.pipeline.*

fun Application.module() {
    intercept(ApplicationCallPipeline.Plugins) {
        if (call.request.origin.scheme == "https") {
            call.response.headers.append(
                HttpHeaders.StrictTransportSecurity,
                "max-age=31536000; includeSubDomains"
            )
        }
    }

    routing {
        get("/") {
            call.respondText("Secure app")
        }
    }
}

That’s the basic pattern. If Ktor is behind a reverse proxy, call.request.origin.scheme may be wrong unless forwarded headers are configured correctly.

Behind Nginx, Caddy, HAProxy, or a cloud load balancer

This is where people get burned.

If TLS terminates at the proxy and Ktor receives plain HTTP internally, your app may think the scheme is http. In that case:

  • trust forwarded headers only from your proxy
  • configure Ktor to respect them
  • or set HSTS at the proxy instead of in Ktor

For Ktor, forwarded headers support usually looks like this:

import io.ktor.server.application.*
import io.ktor.server.plugins.forwardedheaders.*

fun Application.module() {
    install(XForwardedHeaders)
    install(ForwardedHeaders)

    intercept(ApplicationCallPipeline.Plugins) {
        if (call.request.origin.scheme == "https") {
            call.response.headers.append(
                io.ktor.http.HttpHeaders.StrictTransportSecurity,
                "max-age=31536000; includeSubDomains"
            )
        }
    }
}

If your reverse proxy is already adding HSTS, don’t also add it in Ktor unless you know exactly what header value wins. Duplicated security headers are messy and sometimes inconsistent across environments.

My preference:

  • set HSTS at the edge proxy if that’s where TLS terminates
  • set it in Ktor only if Ktor directly serves HTTPS or you want app-level control

A reusable Ktor plugin

If you want something clean and copy-paste friendly, make a tiny plugin.

import io.ktor.server.application.*
import io.ktor.http.*
import io.ktor.util.*

class HstsConfig {
    var maxAge: Long = 31536000
    var includeSubDomains: Boolean = true
    var preload: Boolean = false
    var onlyHttps: Boolean = true
}

val Hsts = createApplicationPlugin(name = "Hsts", ::HstsConfig) {
    val headerValue = buildString {
        append("max-age=${pluginConfig.maxAge}")
        if (pluginConfig.includeSubDomains) append("; includeSubDomains")
        if (pluginConfig.preload) append("; preload")
    }

    onCall { call ->
        val isHttps = call.request.origin.scheme == "https"
        if (!pluginConfig.onlyHttps || isHttps) {
            call.response.headers.append(HttpHeaders.StrictTransportSecurity, headerValue)
        }
    }
}

Use it like this:

import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.plugins.forwardedheaders.*

fun Application.module() {
    install(XForwardedHeaders)
    install(ForwardedHeaders)

    install(Hsts) {
        maxAge = 31536000
        includeSubDomains = true
        preload = false
        onlyHttps = true
    }

    routing {
        get("/") {
            call.respondText("HSTS enabled")
        }
    }
}

That gives you one obvious place to manage policy.

Good production values

Conservative rollout

Strict-Transport-Security: max-age=300

Use this if you’re testing in production with a small blast radius.

Normal production

Strict-Transport-Security: max-age=31536000; includeSubDomains

Use this if all subdomains are HTTPS-ready.

Preload-ready

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

Only use this if:

  • your whole domain tree supports HTTPS
  • HTTP always redirects cleanly to HTTPS
  • you understand preload is hard to roll back

Local development and preview environments

Do not casually enable HSTS for shared dev hostnames.

Bad idea:

  • dev.example.com with includeSubDomains if random internal subdomains still use HTTP

Also bad:

  • trying to test HSTS behavior on localhost

Browsers treat localhost specially, and HSTS behavior there isn’t representative of production. Use a dedicated test domain with HTTPS if you want realistic validation.

For local Ktor development, I usually disable HSTS entirely:

fun Application.module() {
    val isProd = environment.config.propertyOrNull("ktor.environment")?.getString() == "prod"

    if (isProd) {
        intercept(ApplicationCallPipeline.Plugins) {
            if (call.request.origin.scheme == "https") {
                call.response.headers.append(
                    io.ktor.http.HttpHeaders.StrictTransportSecurity,
                    "max-age=31536000; includeSubDomains"
                )
            }
        }
    }
}

Or wire it from config:

security.hsts.enabled=true
security.hsts.value=max-age=31536000; includeSubDomains
```text

fun Application.module() { val enabled = environment.config.property(“security.hsts.enabled”).getString().toBoolean() val value = environment.config.property(“security.hsts.value”).getString()

if (enabled) {
    intercept(ApplicationCallPipeline.Plugins) {
        if (call.request.origin.scheme == "https") {
            call.response.headers.append(io.ktor.http.HttpHeaders.StrictTransportSecurity, value)
        }
    }
}

}


## Redirect HTTP to HTTPS too

HSTS does not replace redirects. New visitors still need a normal HTTP-to-HTTPS redirect before their browser has cached the HSTS policy.

If Ktor handles both ports, redirect plain HTTP:

import io.ktor.server.application.* import io.ktor.server.response.* import io.ktor.http.* import io.ktor.server.routing.*

fun Application.httpRedirectModule() { routing { get("{…}") { val host = call.request.host() val uri = call.request.uri call.respondRedirect(“https://$host$uri”, permanent = true) } } }


In real deployments, I’d usually do this at the reverse proxy instead. It’s faster, simpler, and keeps transport policy at the edge.

## How to verify it

Check the response headers:

curl -I https://example.com


Expected output should include:

Strict-Transport-Security: max-age=31536000; includeSubDomains


If you want a quick browser-facing check for HSTS and the rest of your headers, run a scan at [Headertest](https://headertest.com?utm_source=hsts-guide&utm_medium=blog&utm_campaign=article-link).

You should also verify:

- HTTP redirects to HTTPS
- subdomains covered by `includeSubDomains` really support HTTPS
- your proxy and app aren’t sending conflicting HSTS values

## Common mistakes

### Setting preload too early

`preload` looks cool in a checklist. It’s not a badge, it’s a commitment.

If you have old subdomains, vendor endpoints, forgotten admin tools, or mail-related hostnames that still break over HTTPS, don’t preload.

### Using includeSubDomains without inventory

This one hurts more often than people admit. A single forgotten subdomain can become inaccessible in browsers after HSTS sticks.

### Sending HSTS from staging on a real parent domain

If staging lives on a subdomain of your main production domain and you test aggressive HSTS there, be careful. Browser state persists, and people forget what they visited.

### Trying to “remove” HSTS instantly

Rollback is slow because browsers cache the header. You can send:

Strict-Transport-Security: max-age=0


That tells browsers to clear the policy, but only after they successfully reach your site over HTTPS again. If the problem is “users can’t reach the site,” rollback is not instant relief.

## Official docs

For Ktor server configuration and plugins, use the official documentation:

- [Ktor Documentation](https://ktor.io/docs/)

## My default recommendation for Ktor

If you want the short version, this is the setup I’d ship for a normal production app behind a trusted proxy:

import io.ktor.server.application.* import io.ktor.server.plugins.forwardedheaders.* import io.ktor.http.* import io.ktor.util.pipeline.*

fun Application.module() { install(XForwardedHeaders) install(ForwardedHeaders)

intercept(ApplicationCallPipeline.Plugins) {
    if (call.request.origin.scheme == "https") {
        call.response.headers.append(
            HttpHeaders.StrictTransportSecurity,
            "max-age=31536000; includeSubDomains"
        )
    }
}

}


And I’d pair it with:

- HTTP → HTTPS redirect at the proxy
- no `preload` until the whole domain is audited
- lower `max-age` during rollout if I’m not fully confident

That’s enough to get real protection without creating a cleanup project for future-you.