Don't Trust the Client: Validation and Security
Your API is about to face the open internet, where every request is a stranger and some are hostile. The data class deserialized cleanly — but is the title empty? Is someone hammering /login ten thousand times a minute? This post covers the plugins that stand between your logic and a world that doesn’t play nice.
Validating input
Deserialization checks that JSON fits the shape of your model. It does not check that the values make sense — an empty title, a negative quantity, a malformed email all parse fine. The RequestValidation plugin adds that second layer.
implementation("io.ktor:ktor-server-request-validation")
import io.ktor.server.plugins.requestvalidation.*
fun Application.configureValidation() {
install(RequestValidation) {
validate<NewTask> { task ->
when {
task.title.isBlank() ->
ValidationResult.Invalid("title must not be blank")
task.title.length > 200 ->
ValidationResult.Invalid("title must be 200 characters or fewer")
else -> ValidationResult.Valid
}
}
}
}
validate<T> { } runs whenever a handler does call.receive<T>(). Return ValidationResult.Valid to let it through, or ValidationResult.Invalid(reason) to reject it. On rejection the plugin throws a RequestValidationException — which means it slots straight into the StatusPages setup from the error-handling post:
exception<RequestValidationException> { call, cause ->
call.respond(HttpStatusCode.BadRequest, ErrorResponse(cause.reasons.joinToString(", ")))
}
Now every receive<NewTask>() is automatically validated, the rules live in one place, and a bad request comes back as a clean 400 with a useful message — your routes never see invalid data.
CORS: letting browsers in
If a web frontend on a different origin (say app.example.com calling api.example.com) will hit your API, browsers enforce CORS — and without the right headers, those requests fail before your code runs. The CORS plugin adds them:
implementation("io.ktor:ktor-server-cors")
import io.ktor.server.plugins.cors.routing.*
fun Application.configureCORS() {
install(CORS) {
allowHost("app.example.com", schemes = listOf("https"))
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.Authorization) // so the browser can send the JWT
}
}
Be specific. It’s tempting to call anyHost() to make CORS errors go away — don’t, outside of local dev. It tells browsers any website may call your API with the user’s credentials. List the origins you actually serve.
Rate limiting
A public endpoint with no limits is an open invitation to abuse and accidental overload. Ktor’s RateLimit plugin caps how often a client can call you:
implementation("io.ktor:ktor-server-rate-limit")
import io.ktor.server.plugins.ratelimit.*
import kotlin.time.Duration.Companion.minutes
fun Application.configureRateLimit() {
install(RateLimit) {
register(RateLimitName("auth")) {
rateLimiter(limit = 10, refillPeriod = 1.minutes)
}
}
}
A named limiter lets you apply different caps to different routes — strict on /login (to blunt password guessing), looser elsewhere. Apply it by wrapping routes, the same pattern as authenticate:
routing {
rateLimit(RateLimitName("auth")) {
authRoutes() // /login — capped at 10/min per client
}
}
Over the limit, Ktor automatically responds 429 Too Many Requests. You can also register { } a global limiter that applies everywhere.
Sensible headers
The DefaultHeaders plugin sets standard response headers in one place, and is the natural home for basic security headers:
import io.ktor.server.plugins.defaultheaders.*
install(DefaultHeaders) {
header("X-Content-Type-Options", "nosniff")
header("X-Frame-Options", "DENY")
}
The bigger lever, though, is transport: run behind HTTPS in production (usually terminated at a load balancer or reverse proxy) so tokens and payloads aren’t sent in the clear. Headers harden the edges; TLS protects the whole conversation.
Final thoughts
The thread tying these plugins together is a single assumption: the client is not on your side. RequestValidation distrusts the payload, CORS controls who may call from a browser, rate limiting distrusts the request volume, and headers plus TLS harden what’s left. Each is one install and a config function — and notice how validation reuses your StatusPages handler and rate limiting reuses the authenticate-style wrapping. The patterns from earlier posts keep paying off; security here is mostly composing tools you already know how to wire in.
Next: observability — when something does go wrong in production, you’ll want logs and metrics that tell you what and where.
Comments