Beginner
Group 1: Embedded Server and Application Structure
Example 1: Minimal Embedded Server
Ktor runs as an embedded server inside your JVM application rather than deploying a WAR to a container. You choose an engine (Netty, CIO, Jetty), configure it programmatically, and call start(). This makes deployment a single fat JAR with no external server required.
graph TD
A["main#40;#41;"] --> B["embeddedServer#40;Netty#41;"]
B --> C["Install Plugins"]
C --> D["Define Routes"]
D --> E["server.start#40;wait=true#41;"]
E --> F["HTTP Requests"]
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#DE8F05,stroke:#000,color:#fff
style D fill:#029E73,stroke:#000,color:#fff
style E fill:#029E73,stroke:#000,color:#fff
style F fill:#CC78BC,stroke:#000,color:#fff
// build.gradle.kts - Ktor dependencies for embedded Netty server
// dependencies {
// implementation("io.ktor:ktor-server-netty:3.0.3")
// implementation("io.ktor:ktor-server-core:3.0.3")
// implementation("ch.qos.logback:logback-classic:1.4.14")
// }
import io.ktor.server.engine.* // => embeddedServer, ApplicationEngine
import io.ktor.server.netty.* // => Netty engine implementation
import io.ktor.server.application.* // => Application, ApplicationCall
import io.ktor.server.response.* // => respond, respondText
import io.ktor.server.routing.* // => routing, get, post
fun main() {
// embeddedServer creates and configures the server inline
// First arg: engine class (Netty here; alternatives: CIO, Jetty)
// port: TCP port to listen on
val server = embeddedServer(
Netty, // => Use Netty as the HTTP engine
port = 8080, // => Listen on port 8080
host = "0.0.0.0" // => Accept connections from all network interfaces
) {
// This lambda is the Application configure block
// 'this' is an Application instance
routing { // => Install routing plugin inline
get("/") { // => Register GET handler for "/"
call.respondText( // => Send plain text response
"Hello, Ktor!" // => Response body string
) // => HTTP 200 with Content-Type: text/plain
}
}
}
server.start(wait = true) // => Start server and block the main thread
// => wait=false returns immediately (useful in tests)
// => Server listens at http://localhost:8080
// => GET / returns: Hello, Ktor!
}Key Takeaway: embeddedServer(Engine, port) { ... } creates a fully self-contained HTTP server; calling start(wait = true) blocks until the server shuts down.
Why It Matters: Embedding the server eliminates the distinction between application and container. Your JAR is your deployment unit. In production, this simplifies Docker images (no Tomcat needed), enables fast startup for cloud functions, and lets you configure everything in code rather than XML. Teams adopting Ktor reduce operational complexity by removing the servlet container layer entirely.
Example 2: Application Module Pattern
Large Ktor applications organize plugins and routes into module functions rather than one giant main() block. Modules are extension functions on Application that install plugins and define routes, enabling testability and separation of concerns.
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
// Module function: extension on Application, no return value
// This signature allows testApplication to call it directly in tests
fun Application.configureRouting() { // => Extension fn on Application
routing { // => Defines the routing tree
get("/health") {
call.respondText("OK") // => Responds with "OK" and HTTP 200
}
get("/version") {
call.respondText("1.0.0") // => Responds with version string
}
}
}
// Separate module for plugins (keeps concerns isolated)
fun Application.configurePlugins() { // => Another module function
// Install plugins here (logging, serialization, etc.)
// Called before configureRouting so plugins are ready for routes
}
fun main() {
embeddedServer(Netty, port = 8080) {
configurePlugins() // => Install plugins first
configureRouting() // => Then define routes
// => Modules called in order; plugins must be installed before routes use them
}.start(wait = true)
}
// => GET /health => "OK"
// => GET /version => "1.0.0"Key Takeaway: Module functions (extension functions on Application) separate plugin installation from route definitions, making each concern independently testable and composable.
Why It Matters: As applications grow, a single main() block becomes unmaintainable. Module functions let teams own different sections of the application independently. The testApplication testing API calls these same module functions, so integration tests exercise identical configuration as production. This pattern prevents the “works in production but not in tests” class of bugs caused by configuration divergence.
Example 3: Engine Selection and Configuration
Ktor supports three built-in engines: Netty (high-throughput production), CIO (pure Kotlin coroutine-based, minimal dependencies), and Jetty (useful when existing Jetty infrastructure exists). Each engine accepts connector and thread-pool configuration.
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.cio.* // => CIO engine (coroutine I/O)
// --- Option A: Netty (recommended for production) ---
// Netty uses NIO with an optimized pipeline; handles high concurrency
fun startWithNetty() {
embeddedServer(
Netty,
configure = {
// connector block configures the listening socket
connector {
port = 8080 // => TCP port
host = "0.0.0.0" // => Bind to all interfaces
}
// Worker threads handle business logic after I/O decode
workerGroupSize = 4 // => 4 Netty worker threads
// => Default: number of CPU cores * 2
callGroupSize = 4 // => 4 threads for call handling
}
) {
// Application configure block
}.start(wait = true)
}
// --- Option B: CIO (pure Kotlin, smaller footprint) ---
// CIO is ideal when you want minimal external dependencies
// Uses Kotlin coroutines directly for I/O; no Netty/Jetty required
fun startWithCIO() {
embeddedServer(
CIO, // => Pure Kotlin coroutine-based engine
port = 8080,
configure = {
connectionIdleTimeoutSeconds = 45 // => Drop idle connections after 45s
// => Prevents resource exhaustion from stale clients
}
) {
// Application configure block identical to Netty
}.start(wait = true)
}
// => Both engines expose identical Application API
// => Engine choice is infrastructure; application code is unchangedKey Takeaway: Choose Netty for production workloads and maximum throughput; choose CIO for minimal dependencies or when targeting Kotlin Multiplatform environments.
Why It Matters: Engine selection affects throughput, memory use, and dependency footprint without changing a single line of application code. This separation of engine from framework logic means you can benchmark both engines against your specific workload and switch with a one-line change. Production teams running Ktor in Docker containers often choose CIO to keep image sizes small while maintaining full coroutine support.
Group 2: Routing
Example 4: Basic Route Definitions
Ktor’s routing DSL uses functions named after HTTP methods (get, post, put, delete, patch) inside a routing { } block. Each function receives a path string and a handler lambda where call is the ApplicationCall giving access to request and response.
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.http.*
fun Application.configureRouting() {
routing {
// GET handler - read operations, no body
get("/users") { // => Matches GET /users
call.respondText( // => Responds with plain text
"List of users",
status = HttpStatusCode.OK // => Explicit 200 (default anyway)
)
}
// POST handler - create operations, accepts request body
post("/users") { // => Matches POST /users
call.respondText(
"User created",
status = HttpStatusCode.Created // => HTTP 201 Created
)
}
// PUT handler - full resource replacement
put("/users/{id}") { // => {id} is a path parameter
val id = call.parameters["id"] // => Extract "id" from URL path
// => call.parameters returns String?
call.respondText("Updated user $id") // => "Updated user 42" if id="42"
}
// DELETE handler - resource removal
delete("/users/{id}") { // => Matches DELETE /users/{id}
val id = call.parameters["id"]
call.respond(HttpStatusCode.NoContent) // => HTTP 204 No Content
// => No body for successful deletes
}
}
}
// => GET /users => 200 "List of users"
// => POST /users => 201 "User created"
// => PUT /users/42 => 200 "Updated user 42"
// => DELETE /users/42 => 204 (no body)Key Takeaway: HTTP method functions (get, post, put, delete) inside routing { } register handlers; call.parameters["name"] extracts path parameters as nullable strings.
Why It Matters: Ktor’s routing DSL maps directly to REST conventions, making API design readable in code. Using correct HTTP status codes (201 for creation, 204 for deletion) is not just convention - it signals intent to API consumers and enables correct caching behavior in browsers and HTTP clients. Teams that enforce proper status codes reduce client-side error handling complexity.
Example 5: Route Parameters and Query Strings
Path parameters capture dynamic URL segments while query parameters handle optional filtering and pagination. Ktor provides both through call.parameters (path) and call.request.queryParameters (query string).
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Application.configureRouting() {
routing {
// Path parameter: captures required URL segment
// Curly braces {name} define a named segment
get("/users/{id}") {
val id = call.parameters["id"] // => String? from path segment
// => null if not present (shouldn't happen here)
val userId = id?.toIntOrNull() // => Convert to Int; null if non-numeric
?: return@get call.respondText( // => Early return if invalid
"Invalid user ID",
status = io.ktor.http.HttpStatusCode.BadRequest
)
call.respondText("User ID: $userId") // => "User ID: 42"
}
// Query parameters: optional, come after '?'
// URL: GET /search?q=kotlin&page=2&size=10
get("/search") {
val query = call.request.queryParameters["q"] // => "kotlin" or null
val page = call.request.queryParameters["page"] // => "2" or null
?.toIntOrNull() ?: 1 // => Default page = 1
val size = call.request.queryParameters["size"] // => "10" or null
?.toIntOrNull() ?: 20 // => Default size = 20
if (query == null) {
call.respondText("Missing 'q' parameter",
status = io.ktor.http.HttpStatusCode.BadRequest)
return@get // => Exit handler early
}
call.respondText(
"Searching for '$query', page=$page, size=$size"
)
// => "Searching for 'kotlin', page=2, size=10"
}
// Wildcard path: {...} captures remaining path segments
get("/files/{path...}") {
val filePath = call.parameters.getAll("path")
?.joinToString("/") // => Joins ["docs", "api", "v1"] -> "docs/api/v1"
call.respondText("File: $filePath") // => "File: docs/api/v1"
}
}
}
// => GET /users/42 => "User ID: 42"
// => GET /users/abc => 400 "Invalid user ID"
// => GET /search?q=kotlin => "Searching for 'kotlin', page=1, size=20"
// => GET /files/docs/api/v1 => "File: docs/api/v1"Key Takeaway: call.parameters["name"] returns nullable path segments; call.request.queryParameters["name"] returns nullable query params; always provide defaults or return early with BadRequest for invalid input.
Why It Matters: Proper parameter validation at the routing layer prevents invalid data from reaching business logic. Returning 400 BadRequest with a clear message (rather than throwing a NullPointerException or returning 500) dramatically improves API debuggability. Production APIs that validate and document parameter requirements reduce support burden by making incorrect usage self-evident.
Example 6: Route Groups and Nested Routes
Ktor supports nested routing that mirrors URL hierarchy. The route() function creates a path prefix scope; handlers inside inherit the prefix. This avoids repetition and groups related endpoints logically.
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.http.*
fun Application.configureRouting() {
routing {
// Top-level route group with shared prefix "/api/v1"
route("/api/v1") { // => All children prefixed with /api/v1
// Nested group for /api/v1/users
route("/users") { // => All children prefixed with /api/v1/users
get { // => GET /api/v1/users (no additional path)
call.respondText("All users")
}
get("/{id}") { // => GET /api/v1/users/{id}
val id = call.parameters["id"]
call.respondText("User $id")
}
post { // => POST /api/v1/users
call.respond(HttpStatusCode.Created)
}
route("/{userId}/posts") { // => Another level of nesting
get { // => GET /api/v1/users/{userId}/posts
val userId = call.parameters["userId"]
call.respondText("Posts for user $userId")
}
}
}
// Separate nested group for /api/v1/products
route("/products") { // => /api/v1/products prefix
get { // => GET /api/v1/products
call.respondText("All products")
}
}
}
}
}
// => GET /api/v1/users => "All users"
// => GET /api/v1/users/42 => "User 42"
// => POST /api/v1/users => 201 Created
// => GET /api/v1/users/42/posts => "Posts for user 42"
// => GET /api/v1/products => "All products"Key Takeaway: route("/prefix") { } creates a path scope; nested route() calls compose prefixes; this mirrors the URL hierarchy in code structure, improving readability.
Why It Matters: As REST APIs grow beyond a handful of endpoints, flat route lists become unmaintainable. Nested routes create a one-to-one mapping between URL structure and code structure. This means developers find relevant handlers by following the URL path in the source tree. Teams report significantly less time navigating routing files when routes mirror the API hierarchy rather than being listed alphabetically or by creation order.
Example 7: Route Extensions and Separate Files
Defining all routes in one file creates merge conflicts in team environments. Ktor’s extension function pattern lets you split routes across files while keeping the DSL structure.
// --- File: UserRoutes.kt ---
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.http.*
// Extension function on Route (not Application) to define sub-routes
// This can be called inside any routing block
fun Route.userRoutes() { // => Extension on Route DSL
route("/users") {
get {
call.respondText("GET users")
}
post {
call.respond(HttpStatusCode.Created)
}
delete("/{id}") {
call.respond(HttpStatusCode.NoContent)
}
}
}
// --- File: ProductRoutes.kt ---
fun Route.productRoutes() { // => Another Route extension
route("/products") {
get {
call.respondText("GET products")
}
}
}
// --- File: Application.kt ---
fun Application.configureRouting() {
routing {
route("/api/v1") {
userRoutes() // => Registers /api/v1/users routes
// => Calls the extension fn, which adds to this Route
productRoutes() // => Registers /api/v1/products routes
}
}
}
// => GET /api/v1/users => "GET users"
// => POST /api/v1/users => 201
// => GET /api/v1/products => "GET products"Key Takeaway: Extension functions on Route let you define route groups in separate files and compose them cleanly inside routing { } blocks.
Why It Matters: Splitting routes into feature files (UserRoutes.kt, ProductRoutes.kt, OrderRoutes.kt) enables parallel development without merge conflicts. Each feature team owns their route file. The extension function pattern also enables shared middleware setup per feature group - for example, all user routes can install authentication in one place. This is the standard Ktor convention for structuring production applications.
Group 3: Request and Response
Example 8: Reading Request Bodies
POST and PUT handlers need to read request bodies. Ktor provides receive<T>() on ApplicationCall to deserialize bodies. For plain text or raw bytes, use receiveText() or receiveChannel().
import io.ktor.server.application.*
import io.ktor.server.request.* // => receive, receiveText, receiveParameters
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.http.*
fun Application.configureRouting() {
routing {
// Read plain text body
post("/echo") {
val body = call.receiveText() // => Reads entire body as String
// => Suspends until body fully received
call.respondText("Echo: $body")
}
// Read URL-encoded form data (application/x-www-form-urlencoded)
post("/login") {
val params = call.receiveParameters() // => Parses form-encoded body
// => Returns Parameters map
val username = params["username"] // => "alice" or null
val password = params["password"] // => "secret" or null
if (username == null || password == null) {
call.respond(HttpStatusCode.BadRequest)
return@post
}
// In real code: verify credentials against database
call.respondText("Logged in as $username")
}
// Read raw bytes (for binary data, file uploads handled separately)
post("/binary") {
val bytes = call.receive<ByteArray>() // => Reads body as raw bytes
call.respondText("Received ${bytes.size} bytes")
}
}
}
// => POST /echo body="hello" => "Echo: hello"
// => POST /login username=alice&password=secret => "Logged in as alice"
// => POST /binary (binary body) => "Received 1024 bytes"Key Takeaway: call.receiveText() reads raw body strings; call.receiveParameters() parses URL-encoded forms; call.receive<T>() deserializes to a type (requires content negotiation plugin for JSON).
Why It Matters: Understanding the different receive methods prevents a common bug: calling receive() without a content negotiation plugin installed throws a confusing exception rather than returning a useful error message. Production handlers should always validate received data before processing, and using receiveParameters() for form data correctly handles URL encoding that would corrupt data if read as raw text.
Example 9: Response Building
Ktor’s response API provides multiple respond* methods for different content types. Using the correct method with the correct Content-Type header ensures clients parse responses correctly.
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.http.*
fun Application.configureRouting() {
routing {
// Plain text response - sets Content-Type: text/plain; charset=UTF-8
get("/text") {
call.respondText("Plain text response") // => 200 text/plain
}
// HTML response - sets Content-Type: text/html; charset=UTF-8
get("/html") {
call.respondText(
"<h1>Hello HTML</h1>",
ContentType.Text.Html // => Sets Content-Type header
)
}
// Custom status code
get("/not-found") {
call.respondText(
"Resource not found",
status = HttpStatusCode.NotFound // => HTTP 404
)
}
// Respond with HttpStatusCode only (no body)
delete("/resource") {
call.respond(HttpStatusCode.NoContent) // => HTTP 204, empty body
}
// Custom headers on response
get("/with-headers") {
call.response.headers.append( // => Add response header
"X-Custom-Header", "my-value" // => Name, value pair
)
call.response.headers.append(
HttpHeaders.CacheControl, "no-cache" // => Standard header constant
)
call.respondText("Response with custom headers")
}
// Redirect response
get("/old-path") {
call.respondRedirect( // => Sets Location header
"/new-path", // => Redirect target URL
permanent = true // => true = 301, false = 302
)
}
}
}
// => GET /text => 200 "Plain text response" (text/plain)
// => GET /html => 200 "<h1>Hello HTML</h1>" (text/html)
// => GET /not-found => 404 "Resource not found"
// => DELETE /resource => 204 (no body)
// => GET /with-headers => 200 + X-Custom-Header: my-value
// => GET /old-path => 301 Location: /new-pathKey Takeaway: Use typed respond* methods to set both status and content type correctly; add custom headers via call.response.headers.append() before calling respond*.
Why It Matters: Correct Content-Type headers are not optional - browsers and API clients parse response bodies differently based on this header. An API returning JSON as text/plain breaks clients that check content type before parsing. Proper 301 vs 302 redirects affect SEO and client caching. Production teams use status code linting and contract testing to catch incorrect response metadata before it reaches clients.
Group 4: Content Negotiation and JSON
Example 10: Installing Content Negotiation with kotlinx.serialization
Content negotiation lets Ktor automatically serialize response objects to JSON and deserialize request bodies based on Accept and Content-Type headers. The kotlinx.serialization engine is the recommended choice for Kotlin projects.
graph LR
A["Client Request<br/>Content-Type: application/json"] --> B["ContentNegotiation Plugin"]
B --> C["Deserialize Body<br/>to Data Class"]
C --> D["Route Handler"]
D --> E["Return Data Class"]
E --> F["Serialize to JSON"]
F --> G["Client Response<br/>application/json"]
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#DE8F05,stroke:#000,color:#fff
style D fill:#029E73,stroke:#000,color:#fff
style E fill:#029E73,stroke:#000,color:#fff
style F fill:#DE8F05,stroke:#000,color:#fff
style G fill:#0173B2,stroke:#000,color:#fff
// build.gradle.kts additions:
// implementation("io.ktor:ktor-server-content-negotiation:3.0.3")
// implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.3")
// implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// Also add: id("org.jetbrains.kotlin.plugin.serialization") to plugins block
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.* // => ContentNegotiation plugin
import io.ktor.serialization.kotlinx.json.* // => json() converter
import io.ktor.server.response.*
import io.ktor.server.request.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
// @Serializable generates serializer at compile time
// No reflection needed: fast and works with R8/ProGuard
@Serializable
data class User( // => Data class with generated serializer
val id: Int, // => Maps to JSON "id" field
val name: String, // => Maps to JSON "name" field
val email: String // => Maps to JSON "email" field
)
@Serializable
data class CreateUserRequest(
val name: String,
val email: String
)
fun Application.configureContentNegotiation() {
install(ContentNegotiation) { // => Install the plugin
json() // => Register JSON converter (kotlinx.serialization)
// => json() can accept Json { ... } config block
// json(Json {
// prettyPrint = true // => Indent JSON output (useful for debugging)
// isLenient = true // => Accept unquoted keys (non-standard)
// ignoreUnknownKeys = true // => Don't fail on extra JSON fields from client
// })
}
}
fun Application.configureRouting() {
routing {
// Return data class - automatically serialized to JSON
get("/users/{id}") {
val id = call.parameters["id"]?.toIntOrNull() ?: 0
val user = User(id, "Alice", "alice@example.com")
call.respond(user) // => Serializes User to JSON automatically
// => {"id":1,"name":"Alice","email":"alice@example.com"}
}
// Receive JSON body - automatically deserialized
post("/users") {
val request = call.receive<CreateUserRequest>() // => Deserializes JSON body
// => Content-Type must be application/json
val newUser = User(42, request.name, request.email)
call.respond(newUser) // => Returns created user as JSON
}
}
}
// => GET /users/1
// Response: {"id":1,"name":"Alice","email":"alice@example.com"}
// => POST /users {"name":"Bob","email":"bob@example.com"}
// Response: {"id":42,"name":"Bob","email":"bob@example.com"}Key Takeaway: install(ContentNegotiation) { json() } enables automatic JSON serialization; @Serializable data classes serialize without reflection using compile-time generated code.
Why It Matters: Content negotiation eliminates the manual ObjectMapper.writeValueAsString() calls scattered across controllers in traditional Java frameworks. The compile-time serialization in kotlinx.serialization catches type mismatches at build time rather than runtime, preventing JsonProcessingException in production. Enabling ignoreUnknownKeys = true in your JSON config prevents API versioning breakage when clients send fields your current version doesn’t recognize.
Example 11: JSON Response Customization
Production APIs often need camelCase/snake_case field name transformations, null handling, and custom serializers. kotlinx.serialization provides annotations and configuration options to control serialization output.
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
// @SerialName overrides the JSON key name
@Serializable
data class Product(
val id: Int,
@SerialName("product_name") // => JSON key is "product_name", not "productName"
val productName: String,
@SerialName("unit_price")
val unitPrice: Double,
val inStock: Boolean? = null // => Nullable field; null by default
)
fun Application.configureContentNegotiation() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true // => Indented output (disable in production for size)
ignoreUnknownKeys = true // => Don't fail on extra client fields
encodeDefaults = false // => Don't serialize null defaults
// => Reduces payload size for optional fields
coerceInputValues = true // => Convert JSON null to Kotlin default
})
}
}
fun Application.configureRouting() {
routing {
get("/product") {
val product = Product(
id = 1,
productName = "Widget", // => In JSON: "product_name": "Widget"
unitPrice = 9.99, // => In JSON: "unit_price": 9.99
inStock = null // => With encodeDefaults=false: omitted from JSON
)
call.respond(product)
// => Response (pretty-printed):
// => {
// => "id": 1,
// => "product_name": "Widget",
// => "unit_price": 9.99
// => } (inStock omitted because null and encodeDefaults=false)
}
}
}Key Takeaway: @SerialName("snake_case") overrides JSON key names; encodeDefaults = false omits null fields; ignoreUnknownKeys = true prevents forward-compatibility breakage.
Why It Matters: Many external clients and databases use snake_case while Kotlin conventions use camelCase. @SerialName bridges this without runtime overhead. Omitting null fields (encodeDefaults = false) reduces JSON payload size - critical for mobile clients on metered connections. Enabling ignoreUnknownKeys is essential for rolling deployments where old server and new client versions coexist temporarily, preventing needless 400 errors during deployment windows.
Group 5: Status Pages and Error Handling
Example 12: StatusPages Plugin for Centralized Error Handling
The StatusPages plugin intercepts exceptions and HTTP status codes, routing them to centralized handlers rather than letting each route handle errors individually. This produces consistent error response formats across all endpoints.
// build.gradle.kts: implementation("io.ktor:ktor-server-status-pages:3.0.3")
import io.ktor.server.application.*
import io.ktor.server.plugins.statuspages.* // => StatusPages plugin
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
// Consistent error response shape for all API errors
@Serializable
data class ErrorResponse(
val code: Int,
val message: String,
val details: String? = null
)
// Custom exception classes for domain errors
class NotFoundException(message: String) : RuntimeException(message)
class ValidationException(message: String) : RuntimeException(message)
fun Application.configureStatusPages() {
install(StatusPages) { // => Install status pages plugin
// Handle specific exception type
exception<NotFoundException> { call, cause ->
// 'cause' is the thrown exception instance
call.respond(
HttpStatusCode.NotFound, // => HTTP 404
ErrorResponse(404, cause.message ?: "Not found")
)
}
// Handle validation errors
exception<ValidationException> { call, cause ->
call.respond(
HttpStatusCode.UnprocessableEntity, // => HTTP 422
ErrorResponse(422, "Validation failed", cause.message)
)
}
// Catch-all for unhandled exceptions (prevents stack traces leaking)
exception<Throwable> { call, cause ->
// Log the real error server-side (not shown to client)
call.application.environment.log.error("Unhandled error", cause)
call.respond(
HttpStatusCode.InternalServerError, // => HTTP 500
ErrorResponse(500, "Internal server error")
// => Never return cause.message here: security risk
)
}
// Handle specific HTTP status codes (e.g., route not found = 404)
status(HttpStatusCode.NotFound) { call, status ->
call.respond(status, ErrorResponse(404, "Route not found"))
}
}
}
fun Application.configureRouting() {
routing {
get("/users/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw ValidationException("ID must be a number")
// => ValidationException caught by StatusPages -> 422
if (id > 100) throw NotFoundException("User $id not found")
// => NotFoundException caught by StatusPages -> 404
call.respondText("User $id")
}
}
}
// => GET /users/abc => 422 {"code":422,"message":"Validation failed","details":"ID must be a number"}
// => GET /users/200 => 404 {"code":404,"message":"User 200 not found"}
// => GET /unknown => 404 {"code":404,"message":"Route not found"}Key Takeaway: install(StatusPages) { exception<T> { ... } } intercepts exceptions thrown from handlers; the catch-all exception<Throwable> prevents internal details from leaking in error responses.
Why It Matters: Consistent error response shapes are a contract between your API and its consumers. Clients that receive varying error formats must write complex error handling logic. StatusPages enforces a single error shape across all endpoints without coupling every route handler to error formatting. The catch-all exception handler is a security requirement: stack traces in error responses reveal internal implementation details that attackers use to craft targeted exploits.
Example 13: Custom HTTP Status Handling
Beyond exceptions, StatusPages also intercepts when Ktor itself would return 404 (no matching route), 405 (method not allowed), or other framework-level errors, letting you maintain consistent error format even for infrastructure-level responses.
import io.ktor.server.application.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.response.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
@Serializable
data class ApiError(val status: Int, val error: String)
fun Application.configureStatusPages() {
install(StatusPages) {
// Intercept 404 from routing (no matching route found)
status(HttpStatusCode.NotFound) { call, status ->
call.respond(
status, // => Pass through the 404 status
ApiError(404, "Endpoint not found: ${call.request.uri}")
)
}
// Intercept 405 from routing (route exists but wrong method)
status(HttpStatusCode.MethodNotAllowed) { call, status ->
call.respond(
status,
ApiError(405, "Method ${call.request.httpMethod.value} not allowed here")
)
}
// Intercept 401 set by authentication plugin
status(HttpStatusCode.Unauthorized) { call, status ->
call.respond(
status,
ApiError(401, "Authentication required")
)
}
// Intercept range of status codes using statusFile
// (alternative: use status() for each code individually)
statusFile(
HttpStatusCode.InternalServerError, // => Handle 500 with template file
filePattern = "error-#.html" // => Serves error-500.html from resources
)
}
}
// => GET /nonexistent => 404 {"status":404,"error":"Endpoint not found: /nonexistent"}
// => POST /users (if only GET) => 405 {"status":405,"error":"Method POST not allowed here"}
// => GET /protected (no auth) => 401 {"status":401,"error":"Authentication required"}Key Takeaway: Use status(HttpStatusCode.X) { ... } to intercept specific HTTP codes that Ktor generates; this includes routing 404s and authentication 401s, ensuring consistent error format across all error sources.
Why It Matters: APIs that return HTML 404 pages for unknown routes while returning JSON for application errors create parsing failures in API clients. Intercepting framework-level status codes at the StatusPages layer guarantees uniform JSON error responses regardless of whether the error originated in your code or Ktor’s routing layer. This is especially important for mobile clients that perform strict content-type checking before parsing responses.
Group 6: Static Content
Example 14: Serving Static Files
Ktor’s static content plugin serves files from the filesystem or classpath resources. This handles CSS, JavaScript, images, and other static assets without writing per-file route handlers.
// build.gradle.kts: implementation("io.ktor:ktor-server-static:3.0.3")
// (included in ktor-server-core for Ktor 3.x)
import io.ktor.server.application.*
import io.ktor.server.http.content.* // => static, staticResources, staticFiles
import io.ktor.server.routing.*
fun Application.configureStaticContent() {
routing {
// Serve files from filesystem directory
staticFiles(
remotePath = "/static", // => URL prefix: /static/file.js
dir = java.io.File("static") // => Local directory on filesystem
)
// Serve files from classpath resources (bundled in JAR)
// Files go in src/main/resources/static/
staticResources(
remotePath = "/assets", // => URL prefix: /assets/style.css
basePackage = "static" // => Classpath package: resources/static/
)
// Single-page application (SPA) setup:
// Serve index.html for all unmatched routes (client-side routing)
staticResources(
remotePath = "/app",
basePackage = "webapp"
) {
default("index.html") // => Fallback to index.html for unknown paths
// => Enables React/Vue router to handle routing
}
}
}
// => GET /static/script.js => Serves static/script.js from filesystem
// => GET /assets/style.css => Serves resources/static/style.css from classpath
// => GET /app/dashboard => Serves resources/webapp/index.html (SPA fallback)Key Takeaway: staticFiles() serves from the filesystem; staticResources() serves from the classpath (bundled in JAR); both accept a remotePath URL prefix and file source path.
Why It Matters: For simple applications, serving static assets from Ktor eliminates the need for a separate Nginx reverse proxy just for asset serving. The classpath variant bundles assets into the JAR, creating a single deployable artifact. For larger applications, use Nginx or a CDN in front of Ktor for static assets, but during development and for small projects, Ktor’s static content plugin provides a zero-configuration solution that removes operational complexity.
Group 7: Templates
Example 15: FreeMarker Templates
Ktor integrates with FreeMarker for server-side HTML rendering. Templates separate presentation from logic, making HTML maintainable without string concatenation in route handlers.
// build.gradle.kts: implementation("io.ktor:ktor-server-freemarker:3.0.3")
import freemarker.cache.ClassTemplateLoader
import io.ktor.server.application.*
import io.ktor.server.freemarker.* // => FreeMarker plugin
import io.ktor.server.response.*
import io.ktor.server.routing.*
// Data class passed to template as model
data class UserViewModel(
val name: String,
val items: List<String>
)
fun Application.configureFreeMarker() {
install(FreeMarker) { // => Install FreeMarker plugin
templateLoader = ClassTemplateLoader(
this::class.java.classLoader, // => Load templates from classpath
"templates" // => templates/ directory in resources
)
// defaultEncoding = "UTF-8" // => Default; explicit for clarity
}
}
fun Application.configureRouting() {
routing {
get("/dashboard") {
val model = UserViewModel(
name = "Alice",
items = listOf("Report A", "Report B", "Report C")
)
call.respond(
FreeMarkerContent( // => Wraps template name + model
"dashboard.ftl", // => Template file in resources/templates/
mapOf("user" to model) // => Model variables available in template
)
)
// => Renders resources/templates/dashboard.ftl with user variable
}
}
}
// --- resources/templates/dashboard.ftl ---
// <!DOCTYPE html>
// <html>
// <head><title>Dashboard - ${user.name}</title></head>
// <body>
// <h2>Welcome, ${user.name}</h2>
// <ul>
// <#list user.items as item>
// <li>${item}</li>
// </#list>
// </ul>
// </body>
// </html>
//
// => Rendered output for Alice with 3 items:
// => <h2>Welcome, Alice</h2>
// => <ul><li>Report A</li><li>Report B</li><li>Report C</li></ul>Key Takeaway: install(FreeMarker) { templateLoader = ClassTemplateLoader(...) } configures template loading; call.respond(FreeMarkerContent("template.ftl", model)) renders a template with data.
Why It Matters: Server-side rendering with templates remains the right choice for content-heavy web applications, admin interfaces, and SEO-critical pages where JavaScript-heavy SPAs add unnecessary complexity. FreeMarker’s ClassTemplateLoader bundles templates in the JAR, maintaining the single-artifact deployment model. Template caching is enabled by default in FreeMarker, preventing repeated filesystem reads in production that would degrade response times under load.
Example 16: Thymeleaf Templates
Thymeleaf is an alternative template engine that uses valid HTML with th: attribute syntax, enabling designers to open templates directly in browsers without server rendering.
// build.gradle.kts: implementation("io.ktor:ktor-server-thymeleaf:3.0.3")
import io.ktor.server.application.*
import io.ktor.server.thymeleaf.* // => Thymeleaf plugin
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
fun Application.configureThymeleaf() {
install(Thymeleaf) { // => Install Thymeleaf plugin
setTemplateResolver(
ClassLoaderTemplateResolver().apply {
prefix = "templates/" // => Load from resources/templates/ directory
suffix = ".html" // => Template files end in .html
characterEncoding = "UTF-8"
isCacheable = true // => Cache parsed templates in production
// => Set false during development for live reload
}
)
}
}
fun Application.configureRouting() {
routing {
get("/profile/{username}") {
val username = call.parameters["username"] ?: "Guest"
call.respond(
ThymeleafContent( // => Thymeleaf response wrapper
"profile", // => Template name (maps to templates/profile.html)
mapOf( // => Variables available in template
"username" to username,
"posts" to listOf("First post", "Second post")
)
)
)
}
}
}
// --- resources/templates/profile.html ---
// <!DOCTYPE html>
// <html xmlns:th="http://www.thymeleaf.org">
// <head><title th:text="${username} + ' Profile'">Profile</title></head>
// <body>
// <h1 th:text="'Hello, ' + ${username}">Hello, User</h1>
// <ul>
// <li th:each="post : ${posts}" th:text="${post}">Post title</li>
// </ul>
// </body>
// </html>
//
// => GET /profile/alice
// => <h1>Hello, alice</h1>
// => <li>First post</li><li>Second post</li>Key Takeaway: install(Thymeleaf) { setTemplateResolver(...) } and call.respond(ThymeleafContent("name", model)) enable Thymeleaf rendering with natural HTML templates that remain valid HTML files.
Why It Matters: Thymeleaf’s natural templates allow designers to prototype with real HTML that browsers render correctly even before server integration. This accelerates UI development in teams with dedicated frontend designers who use browser-based tools. Unlike FreeMarker’s ${} syntax which breaks HTML validation, Thymeleaf th: attributes preserve valid HTML, enabling CSS design and preview workflows without running the server.
Group 8: Headers and Cookies
Example 17: Reading and Writing Headers
HTTP headers convey metadata about requests and responses. Ktor exposes request headers through call.request.headers and response headers through call.response.headers. Standard headers have named constants in HttpHeaders.
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.http.*
fun Application.configureRouting() {
routing {
get("/headers-demo") {
// Read standard request headers
val accept = call.request.headers[HttpHeaders.Accept]
// => "application/json" or null
val contentType = call.request.contentType()
// => ContentType parsed from Content-Type header
val userAgent = call.request.headers[HttpHeaders.UserAgent]
// => "Mozilla/5.0 ..." or null
// Read custom request header
val apiVersion = call.request.headers["X-API-Version"]
// => "v2" or null (custom header)
// Write response headers before respond()
call.response.headers.apply {
append(HttpHeaders.CacheControl, "public, max-age=3600")
// => Browser caches response for 1 hour
append("X-Request-ID", "req-${System.currentTimeMillis()}")
// => Custom header for request tracing
append(HttpHeaders.ContentLanguage, "en-US")
// => Declare response language
}
call.respondText(
"Accept: $accept, User-Agent: $userAgent, API-Version: $apiVersion"
)
}
// CORS preflight check example (handled by CORS plugin, but manual version):
options("/resource") {
call.response.headers.apply {
append(HttpHeaders.AccessControlAllowOrigin, "https://myapp.com")
append(HttpHeaders.AccessControlAllowMethods, "GET, POST, PUT")
append(HttpHeaders.AccessControlMaxAge, "86400") // => Cache preflight 24h
}
call.respond(HttpStatusCode.NoContent) // => 204 for OPTIONS preflight
}
}
}
// => GET /headers-demo (with User-Agent: curl/7.8, X-API-Version: v2)
// Response headers: Cache-Control: public, max-age=3600
// X-Request-ID: req-1234567890
// Body: Accept: null, User-Agent: curl/7.8, API-Version: v2Key Takeaway: Read headers with call.request.headers[HttpHeaders.Name]; write response headers with call.response.headers.append() before calling respond*().
Why It Matters: Headers are the metadata layer of HTTP that many developers overlook until issues arise in production. Cache-Control headers prevent unnecessary repeat requests to your server, reducing load by 30-80% for cacheable resources. X-Request-ID headers enable distributed tracing: pass the same ID through your service calls to correlate logs across microservices. Security headers like Strict-Transport-Security and Content-Security-Policy prevent entire classes of attacks.
Example 18: Cookies
Cookies store small amounts of state in the client browser. Ktor provides call.request.cookies for reading and call.response.cookies.append() for writing cookies with full control over security attributes.
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.http.*
fun Application.configureRouting() {
routing {
// Set a cookie
get("/set-cookie") {
call.response.cookies.append( // => Adds Set-Cookie header to response
Cookie(
name = "session_id", // => Cookie name
value = "abc123xyz", // => Cookie value
maxAge = 3600, // => Expires in 1 hour (seconds)
// => null = session cookie (browser close expires)
secure = true, // => Only sent over HTTPS
// => CRITICAL: prevents cookie theft via HTTP
httpOnly = true, // => Not accessible via JavaScript
// => Prevents XSS cookie theft
path = "/", // => Valid for all paths on the domain
domain = null, // => Valid for current domain only (null = current)
extensions = mapOf( // => SameSite attribute via extension
"SameSite" to "Strict" // => Only sent for same-site requests
// => Prevents CSRF attacks
)
)
)
call.respondText("Cookie set!")
}
// Read a cookie
get("/read-cookie") {
val sessionId = call.request.cookies["session_id"]
// => "abc123xyz" or null if not set
if (sessionId == null) {
call.respondText("No session", status = HttpStatusCode.Unauthorized)
return@get
}
call.respondText("Session: $sessionId")
}
// Delete a cookie (set same name with maxAge=0)
get("/clear-cookie") {
call.response.cookies.appendExpired("session_id")
// => Sets Set-Cookie with Max-Age=0
// => Browser deletes the cookie
call.respondText("Cookie cleared")
}
}
}
// => GET /set-cookie => Sets session_id cookie, responds "Cookie set!"
// => GET /read-cookie (with cookie) => "Session: abc123xyz"
// => GET /clear-cookie => Deletes session_id cookieKey Takeaway: Use Cookie(httpOnly = true, secure = true, extensions = mapOf("SameSite" to "Strict")) as the minimum security baseline; appendExpired() deletes cookies by setting Max-Age=0.
Why It Matters: Cookie security attributes are a defense-in-depth requirement. HttpOnly prevents XSS attacks from stealing session tokens via document.cookie. Secure prevents transmission over unencrypted HTTP connections. SameSite=Strict blocks CSRF attacks where other sites submit forms to your API using the victim’s cookies. Missing any of these attributes in a production authentication system creates exploitable vulnerabilities that OWASP consistently ranks among the most common web security issues.
Group 9: Logging
Example 19: CallLogging Plugin
The CallLogging plugin logs HTTP requests and responses including method, URL, status code, and duration. It integrates with SLF4J-compatible logging frameworks like Logback.
// build.gradle.kts: implementation("io.ktor:ktor-server-call-logging:3.0.3")
// implementation("ch.qos.logback:logback-classic:1.4.14")
import io.ktor.server.application.*
import io.ktor.server.plugins.calllogging.* // => CallLogging plugin
import io.ktor.server.request.*
import io.ktor.server.routing.*
import io.ktor.server.response.*
import org.slf4j.event.Level
fun Application.configureLogging() {
install(CallLogging) { // => Install the call logging plugin
level = Level.INFO // => Log at INFO level
// => Use Level.DEBUG for verbose request details
// Filter: only log specific requests (e.g., skip health checks)
filter { call -> // => Predicate: return true to log this call
call.request.path().startsWith("/api")
// => Logs /api/** requests; skips /health, /metrics, /static/**
}
// Customize log format
format { call -> // => Custom message format lambda
val status = call.response.status() // => HttpStatusCode or null
val method = call.request.httpMethod.value // => "GET", "POST", etc.
val uri = call.request.uri // => "/api/users?page=1"
val duration = call.processingTimeMillis() // => Time in milliseconds
"$method $uri => ${status?.value} (${duration}ms)"
// => "GET /api/users => 200 (42ms)"
}
// Add MDC (Mapped Diagnostic Context) values to log entries
// Useful for correlating logs by request ID across log lines
mdc("requestId") { // => Add "requestId" to MDC for this call
call.request.headers["X-Request-ID"]
?: "req-${System.nanoTime()}"
// => MDC value: client-provided ID or generated one
}
}
}
// => Log output:
// => INFO ktor.application - GET /api/users => 200 (42ms) {requestId=req-abc123}
// => INFO ktor.application - POST /api/users => 201 (87ms) {requestId=req-def456}
// => (Health check GET /health is NOT logged due to filter)Key Takeaway: install(CallLogging) { level = Level.INFO; format { ... }; filter { ... } } provides structured request logging; mdc() adds correlation IDs for distributed tracing.
Why It Matters: Request logging is the first tool for diagnosing production issues. Without it, you cannot answer basic questions: which endpoints are slow, what error rate is occurring, or which clients are misbehaving. The MDC-based request ID correlation enables cross-service log tracing in microservice architectures - a requirement for any non-trivial distributed system. The filter capability prevents health check endpoints from flooding logs and obscuring real traffic patterns.
Example 20: Structured Logging with Logback
While CallLogging logs HTTP requests, application code needs structured logging for business events. Logback with JSON encoder produces machine-parseable logs that monitoring tools like Datadog, Splunk, and CloudWatch can query.
// resources/logback.xml
// <configuration>
// <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
// <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
// </appender>
// <root level="INFO">
// <appender-ref ref="STDOUT"/>
// </root>
// </configuration>
import io.ktor.server.application.*
import io.ktor.server.routing.*
import io.ktor.server.response.*
import org.slf4j.LoggerFactory
import org.slf4j.MDC
// Get a logger bound to this class - standard SLF4J pattern
private val logger = LoggerFactory.getLogger("UserService")
// => Logger named "UserService"
// => Appears in log output as logger name
fun Application.configureRouting() {
routing {
get("/users/{id}") {
val userId = call.parameters["id"] ?: "unknown"
// Add structured context to all log lines in this block
MDC.put("userId", userId) // => MDC key-value for log correlation
try {
logger.info("Fetching user") // => INFO: Fetching user {userId=42}
// => MDC values appear in JSON logs
// Simulate finding or not finding user
if (userId == "999") {
logger.warn( // => WARN level for expected failures
"User not found",
mapOf("userId" to userId).toString()
)
call.respondText("Not found",
status = io.ktor.http.HttpStatusCode.NotFound)
return@get
}
logger.info("User found successfully")
call.respondText("User $userId")
} catch (e: Exception) {
logger.error("Failed to fetch user: ${e.message}", e)
// => ERROR with stack trace in JSON
throw e // => Re-throw for StatusPages to handle
} finally {
MDC.remove("userId") // => Always clean up MDC to prevent leaks
// => Coroutines reuse threads; stale MDC poisons next request
}
}
}
}
// => JSON log output:
// => {"level":"INFO","logger":"UserService","message":"Fetching user","userId":"42"}
// => {"level":"INFO","logger":"UserService","message":"User found successfully","userId":"42"}Key Takeaway: MDC.put("key", value) adds structured context to all log lines within a try-finally block; always call MDC.remove() in finally to prevent MDC values from leaking into subsequent coroutine executions.
Why It Matters: Unstructured log strings become unsearchable in production at scale. JSON logs with consistent field names enable log aggregation tools to answer questions like “show all errors for userId=42 in the last hour” with a single query. The MDC cleanup in finally prevents a subtle coroutine bug: when coroutines suspend and resume on different threads, stale MDC values from previous requests contaminate new request log lines, causing incorrect correlation in traces.
Group 10: CORS
Example 21: CORS Plugin Configuration
The CORS plugin adds Cross-Origin Resource Sharing headers to responses, allowing browsers to make cross-origin requests to your API. Without correct CORS headers, browsers block API calls from different domains.
graph LR
A["Browser<br/>origin: app.example.com"] -->|"OPTIONS /api/users<br/>Origin: app.example.com"| B["Ktor CORS Plugin"]
B -->|"200 OK<br/>Access-Control-Allow-Origin: app.example.com"| A
A -->|"GET /api/users<br/>Origin: app.example.com"| B
B -->|"200 OK with CORS headers"| C["API Response"]
C --> A
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
// build.gradle.kts: implementation("io.ktor:ktor-server-cors:3.0.3")
import io.ktor.server.application.*
import io.ktor.server.plugins.cors.routing.* // => CORS plugin
import io.ktor.http.*
fun Application.configureCORS() {
install(CORS) { // => Install CORS plugin
// Allowed origins (specific domains)
allowHost("app.example.com") // => Allow requests from https://app.example.com
allowHost(
"localhost:3000", // => Allow local development server
schemes = listOf("http") // => http scheme for localhost
)
// Alternative: allow all origins (NOT recommended for production APIs)
// anyHost() // => Adds: Access-Control-Allow-Origin: *
// // => Disables credentials (cookies won't work)
// Allowed HTTP methods
allowMethod(HttpMethod.Get) // => Allow GET requests
allowMethod(HttpMethod.Post) // => Allow POST requests
allowMethod(HttpMethod.Put) // => Allow PUT requests
allowMethod(HttpMethod.Delete) // => Allow DELETE requests
allowMethod(HttpMethod.Options) // => Required for preflight requests
// Allowed request headers
allowHeader(HttpHeaders.ContentType) // => Allow Content-Type header
allowHeader(HttpHeaders.Authorization) // => Allow Authorization (JWT/Bearer)
allowHeader("X-API-Key") // => Custom header
// => Not using allowHeaders() - be explicit
// Expose response headers to JavaScript
exposeHeader("X-Request-ID") // => JS can read this response header
exposeHeader("X-Rate-Limit-Remaining")
// Allow credentials (cookies, auth headers)
allowCredentials = true // => Required if frontend sends cookies
// => Cannot combine with anyHost()
// Cache preflight results
maxAgeInSeconds = 3600L // => Browser caches preflight for 1 hour
// => Reduces OPTIONS requests to your server
}
}
// => OPTIONS /api/users (preflight from app.example.com)
// Response: Access-Control-Allow-Origin: https://app.example.com
// Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
// Access-Control-Allow-Headers: Content-Type, Authorization
// Access-Control-Max-Age: 3600
// => GET /api/users (with Origin: app.example.com)
// Response includes: Access-Control-Allow-Origin: https://app.example.comKey Takeaway: List specific allowed origins with allowHost() rather than anyHost() in production; allowCredentials = true enables cookies but requires explicit origin allowlisting.
Why It Matters: CORS misconfiguration is one of the most common API security issues. anyHost() with allowCredentials = true is impossible (browsers reject this combination), but anyHost() without credentials silently allows any website to call your API, enabling data exfiltration attacks. Explicit origin allowlisting means only your trusted frontend domains can make credentialed requests. Setting maxAgeInSeconds reduces unnecessary preflight round trips, improving perceived API performance for single-page applications.
Example 22: CORS for Development vs Production
Different CORS configurations for development and production environments prevent overly permissive settings from reaching production while maintaining developer productivity.
import io.ktor.server.application.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.http.*
fun Application.configureCORS() {
// Read environment from application config
val isDevelopment = environment.config
.propertyOrNull("ktor.deployment.environment")
?.getString() == "development" // => true in dev, false in prod
install(CORS) {
if (isDevelopment) {
// Development: allow common local dev server ports
allowHost("localhost:3000", schemes = listOf("http")) // => React dev server
allowHost("localhost:4200", schemes = listOf("http")) // => Angular CLI
allowHost("localhost:5173", schemes = listOf("http")) // => Vite dev server
allowHost("127.0.0.1:3000", schemes = listOf("http")) // => Alternative localhost
} else {
// Production: only allow your real frontend domains
allowHost("myapp.com", schemes = listOf("https")) // => Production domain
allowHost("www.myapp.com", schemes = listOf("https")) // => www subdomain
allowHost("staging.myapp.com", schemes = listOf("https")) // => Staging
}
// Same for both environments
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.Authorization)
allowCredentials = true
maxAgeInSeconds = if (isDevelopment) 60L else 3600L
// => Short cache in dev (changes take effect quickly)
// => Long cache in prod (reduces preflight overhead)
}
}
// => Development: allows localhost:3000, :4200, :5173
// => Production: allows only myapp.com, www.myapp.com, staging.myapp.comKey Takeaway: Read environment.config to branch CORS configuration between development and production; use short maxAgeInSeconds in development for rapid iteration and long values in production for performance.
Why It Matters: Hardcoding production CORS rules during development creates two problems: developers cannot use their local servers, and overly permissive development rules leak into production through copy-paste accidents. Environment-driven configuration means the same application binary deploys safely to production without manual configuration changes. This configuration-as-code approach is auditable in version control, unlike environment-specific server configurations that live only in deployment infrastructure.
Group 11: Default Headers and Compression
Example 23: DefaultHeaders Plugin
The DefaultHeaders plugin automatically adds headers to every response, ensuring consistent metadata without per-handler boilerplate. Server identification, date headers, and security headers belong here.
// build.gradle.kts: implementation("io.ktor:ktor-server-default-headers:3.0.3")
import io.ktor.server.application.*
import io.ktor.server.plugins.defaultheaders.* // => DefaultHeaders plugin
import io.ktor.http.*
fun Application.configureHeaders() {
install(DefaultHeaders) { // => Added to every response automatically
header("X-Engine", "Ktor") // => Custom header on all responses
header("X-API-Version", "1.0") // => Version identifier for all endpoints
header(
HttpHeaders.Server, // => Override "Server" header
"MyApp/1.0" // => Hide Ktor/Netty version (security best practice)
)
// Date header is added automatically by DefaultHeaders
// X-Powered-By is NOT added (unlike Express.js default; good security)
}
}
// => Every response includes:
// => X-Engine: Ktor
// => X-API-Version: 1.0
// => Server: MyApp/1.0
// => Date: Wed, 19 Mar 2026 10:00:00 GMTKey Takeaway: install(DefaultHeaders) { header("Name", "Value") } appends specified headers to all responses automatically; override the Server header to avoid revealing implementation details.
Why It Matters: Exposing Server: ktor-server-netty/3.0.3 in every response tells attackers exactly which framework version to target for known vulnerabilities. Replacing it with a generic identifier removes this information. Conversely, X-API-Version on all responses helps clients detect when they are talking to a different API version than expected, enabling graceful degradation. Adding these headers once in DefaultHeaders is more reliable than trusting every developer to add them in each route handler.
Example 24: Compression Plugin
HTTP compression reduces response body size before transmission. The Compression plugin automatically compresses eligible responses using gzip or deflate, reducing bandwidth usage and improving response times for clients that support it.
// build.gradle.kts: implementation("io.ktor:ktor-server-compression:3.0.3")
import io.ktor.server.application.*
import io.ktor.server.plugins.compression.* // => Compression plugin
import io.ktor.http.*
fun Application.configureCompression() {
install(Compression) { // => Compress eligible responses automatically
gzip { // => gzip compression encoder
priority = 1.0 // => Higher priority than deflate
minimumSize(1024) // => Only compress responses >= 1024 bytes
// => Small responses waste CPU compressing
}
deflate { // => deflate compression encoder
priority = 0.9 // => Lower priority than gzip
minimumSize(1024)
}
// Exclude content types that are already compressed
excludeContentType(ContentType.Image.JPEG) // => JPEG already compressed
excludeContentType(ContentType.Image.PNG) // => PNG already compressed
excludeContentType(ContentType.Video.MPEG) // => Video already compressed
// => Compressing pre-compressed data wastes CPU and may increase size
}
}
// => GET /api/users (with Accept-Encoding: gzip)
// Response: Content-Encoding: gzip, body compressed
// Typical compression: 60-80% size reduction for JSON responses
// => GET /api/users (without Accept-Encoding header)
// Response: no Content-Encoding, body uncompressed (client doesn't support it)
// => GET /image.jpg
// Response: no Content-Encoding (excluded content type)Key Takeaway: install(Compression) { gzip { minimumSize(1024) } } compresses eligible responses; always exclude pre-compressed content types to avoid wasting CPU on content that won’t benefit.
Why It Matters: JSON API responses compress exceptionally well - typical compression ratios of 60-80% mean a 100KB response becomes 20-40KB over the wire. For mobile clients on cellular connections, this directly reduces data usage and perceived latency. The minimumSize(1024) threshold prevents compressing tiny responses where the gzip header overhead exceeds the savings. Production APIs handling high-volume endpoints (user lists, product catalogs) should always enable compression to reduce both server bandwidth costs and client latency.
Group 12: Request Validation and Content Type
Example 25: Request Validation Plugin
The RequestValidation plugin provides a centralized place to validate deserialized request bodies before they reach route handlers, separating validation logic from business logic.
// build.gradle.kts: implementation("io.ktor:ktor-server-request-validation:3.0.3")
import io.ktor.server.application.*
import io.ktor.server.plugins.requestvalidation.* // => RequestValidation plugin
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.response.*
import io.ktor.server.request.*
import io.ktor.server.routing.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
@Serializable
data class CreateUserRequest(
val name: String,
val email: String,
val age: Int
)
fun Application.configureValidation() {
install(RequestValidation) { // => Install validation plugin
validate<CreateUserRequest> { user ->
// Return ValidationResult.Valid or ValidationResult.Invalid(reason)
when {
user.name.isBlank() ->
ValidationResult.Invalid("Name must not be blank")
// => Triggers RequestValidationException
user.email.isEmpty() || !user.email.contains("@") ->
ValidationResult.Invalid("Email must be a valid email address")
user.age < 0 || user.age > 150 ->
ValidationResult.Invalid("Age must be between 0 and 150")
else ->
ValidationResult.Valid // => Passes validation; handler proceeds
}
}
}
// Handle RequestValidationException in StatusPages
install(StatusPages) {
exception<RequestValidationException> { call, cause ->
call.respond(
HttpStatusCode.UnprocessableEntity, // => HTTP 422
mapOf(
"error" to "Validation failed",
"reasons" to cause.reasons // => List of validation messages
)
)
}
}
}
fun Application.configureRouting() {
routing {
post("/users") {
val user = call.receive<CreateUserRequest>()
// => At this point, validation already passed
// => user.name is non-blank, email contains @, age in range
call.respondText("Created: ${user.name}", status = HttpStatusCode.Created)
}
}
}
// => POST /users {"name":"","email":"bad","age":-1}
// => 422 {"error":"Validation failed","reasons":["Name must not be blank"]}
// => POST /users {"name":"Alice","email":"alice@example.com","age":30}
// => 201 "Created: Alice"Key Takeaway: validate<T> { instance -> ValidationResult.Valid or .Invalid("reason") } registers type-specific validators that run automatically after deserialization and before route handlers.
Why It Matters: Separating validation from business logic keeps route handlers focused on happy-path flow. Centralized validators run consistently regardless of which route receives the request type - useful when multiple routes accept the same request body. The RequestValidationException integrates with StatusPages to produce uniform validation error responses, preventing the anti-pattern of duplicate validation error formatting scattered across all POST/PUT handlers.
Group 13: Forwarded Headers and Request Context
Example 26: Forwarded Headers Behind a Proxy
Production Ktor applications typically run behind a reverse proxy (Nginx, load balancer) that sets X-Forwarded-For and X-Forwarded-Proto headers. The ForwardedHeaders plugin correctly extracts the client’s real IP address and protocol.
// build.gradle.kts: implementation("io.ktor:ktor-server-forwarded-header:3.0.3")
import io.ktor.server.application.*
import io.ktor.server.plugins.forwardedheaders.* // => ForwardedHeaders plugins
import io.ktor.server.routing.*
import io.ktor.server.response.*
import io.ktor.server.request.*
fun Application.configureForwardedHeaders() {
// Install ONLY if your server is behind a trusted proxy
// Installing this without a proxy lets clients spoof their IP
install(ForwardedHeaders) // => Processes "Forwarded" header (RFC 7239)
install(XForwardedHeaders) // => Processes "X-Forwarded-For", "X-Forwarded-Proto"
// => Both plugins update call.request.origin
}
fun Application.configureRouting() {
routing {
get("/ip") {
val origin = call.request.origin // => RequestConnectionPoint with real info
// After ForwardedHeaders/XForwardedHeaders installation:
// origin.remoteHost = real client IP (from X-Forwarded-For)
// origin.scheme = original protocol (from X-Forwarded-Proto)
// Without plugins: origin.remoteHost = proxy IP address
val clientIp = origin.remoteHost // => "203.0.113.42" (real client IP)
val scheme = origin.scheme // => "https" (even if proxy-to-ktor is http)
call.respondText("Client IP: $clientIp, Scheme: $scheme")
}
get("/force-https") {
val scheme = call.request.origin.scheme
if (scheme != "https") {
// Redirect to HTTPS version
val host = call.request.host()
val uri = call.request.uri
call.respondRedirect("https://$host$uri", permanent = true)
return@get
}
call.respondText("You are on HTTPS")
}
}
}
// => GET /ip (behind proxy with X-Forwarded-For: 203.0.113.42)
// => "Client IP: 203.0.113.42, Scheme: https"
// => GET /force-https (with X-Forwarded-Proto: http)
// => 301 Location: https://myapp.com/force-httpsKey Takeaway: install(ForwardedHeaders) and install(XForwardedHeaders) make call.request.origin.remoteHost return the real client IP rather than the proxy’s IP; only install these when actually behind a trusted proxy.
Why It Matters: Rate limiting, geolocation, access control, and audit logging all depend on the client’s real IP address. Without ForwardedHeaders, every request appears to come from your load balancer’s IP address, making rate limiting impossible and audit logs meaningless. The security caveat is critical: installing these plugins on a directly-exposed server lets clients lie about their IP by setting the X-Forwarded-For header themselves, undermining all IP-based security measures.
Group 14: Conditional Responses
Example 27: Caching Headers and Conditional Requests
HTTP caching through ETag and Last-Modified headers allows clients to revalidate cached responses without re-downloading unchanged content. Ktor’s CachingHeaders plugin and manual etag/lastModified helpers implement this.
// build.gradle.kts: implementation("io.ktor:ktor-server-caching-headers:3.0.3")
import io.ktor.server.application.*
import io.ktor.server.plugins.cachingheaders.* // => CachingHeaders plugin
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.http.*
import io.ktor.http.content.*
fun Application.configureCachingHeaders() {
install(CachingHeaders) { // => Adds caching headers plugin
options { call, outgoingContent ->
// Return CachingOptions based on content type
when (outgoingContent.contentType?.withoutParameters()) {
ContentType.Text.CSS -> // => CSS files: cache 1 day
CachingOptions(CacheControl.MaxAge(maxAgeSeconds = 86400))
ContentType.Text.JavaScript -> // => JS files: cache 1 week
CachingOptions(CacheControl.MaxAge(maxAgeSeconds = 604800))
ContentType.Application.Json -> // => JSON: no cache (dynamic data)
CachingOptions(CacheControl.NoCache())
else -> null // => No caching header added for other types
}
}
}
}
fun Application.configureRouting() {
routing {
// Manual ETag-based conditional response
get("/api/config") {
val config = mapOf("version" to "1.2.3", "feature_flags" to listOf("dark_mode"))
val etag = config.hashCode().toString() // => ETag derived from content hash
// => Changes when config changes
// Check if client already has this version
val clientEtag = call.request.headers[HttpHeaders.IfNoneMatch]
if (clientEtag == etag) {
call.respond(HttpStatusCode.NotModified) // => 304: client cache is fresh
// => No body sent (saves bandwidth)
return@get
}
call.response.headers.append(HttpHeaders.ETag, etag) // => Tell client ETag value
call.respondText(config.toString()) // => 200 with full response body
}
}
}
// => GET /api/config (first request)
// Response: 200, ETag: "-1234567890", body: {version=1.2.3, ...}
// => GET /api/config (second request with If-None-Match: -1234567890)
// Response: 304 Not Modified (no body, saves bandwidth)
// => GET /api/config (after config changes, new ETag)
// Response: 200, ETag: "987654321", body: {version=1.2.4, ...}Key Takeaway: Return 304 Not Modified when the client’s If-None-Match matches the current ETag; this eliminates redundant data transfer for unchanged resources.
Why It Matters: Conditional requests are essential for high-traffic APIs serving data that changes infrequently. A configuration endpoint receiving 10,000 requests per hour can skip 9,500 response body serializations if most clients already have the current version. This reduces CPU load, bandwidth costs, and client latency simultaneously. ETag-based caching is especially valuable for mobile applications on metered connections, where eliminating unnecessary data transfer directly reduces user costs and improves perceived performance.