Your API has authentication, but that is only one layer of security. Without rate limiting, attackers can brute-force passwords. Without CORS, any website can call your API. Without security headers, your application is vulnerable to clickjacking and XSS attacks.
In this tutorial, you will add three essential security features: CORS configuration, rate limiting, and security headers. These are requirements for any production API.
Why These Security Features Matter
CORS → Controls which websites can call your API
Rate Limiting → Prevents brute-force attacks and abuse
Security Headers → Prevents clickjacking, XSS, and MIME sniffing
Dependencies
Add the Ktor security plugins:
dependencies {
implementation("io.ktor:ktor-server-cors:$ktorVersion")
implementation("io.ktor:ktor-server-rate-limit:$ktorVersion")
implementation("io.ktor:ktor-server-default-headers:$ktorVersion")
}
CORS — Cross-Origin Resource Sharing
CORS controls which websites can make requests to your API. Without CORS configuration, browsers block requests from different domains.
The Problem
Your API runs on api.example.com. Your frontend runs on app.example.com. Without CORS, the browser blocks the frontend from calling the API.
The Solution
fun Application.configureCors() {
install(CORS) {
// Allow common HTTP methods
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch)
allowMethod(HttpMethod.Options)
// Allow common headers
allowHeader(HttpHeaders.Authorization)
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.Accept)
// Allow credentials (cookies, authorization headers)
allowCredentials = true
// In production, specify your frontend domain
// allowHost("app.example.com", schemes = listOf("https"))
anyHost() // For development only
}
}
Important: In production, replace anyHost() with your actual frontend domain. Using anyHost() with allowCredentials = true is a security risk.
Production CORS Configuration
// Production: only allow your frontend
allowHost("app.example.com", schemes = listOf("https"))
allowHost("admin.example.com", schemes = listOf("https"))
// Or if you have a mobile app that does not need CORS
// CORS only applies to browser requests
Rate Limiting
Rate limiting prevents abuse. Without it, an attacker can send thousands of login attempts per second.
Configuration
fun Application.configureRateLimit() {
install(RateLimit) {
// General API rate limit: 60 requests per minute
global {
rateLimiter(limit = 60, refillPeriod = 1.minutes)
}
// Strict rate limit for auth endpoints: 10 per minute
register(RateLimitName("auth")) {
rateLimiter(limit = 10, refillPeriod = 1.minutes)
}
// Upload rate limit: 5 per minute
register(RateLimitName("upload")) {
rateLimiter(limit = 5, refillPeriod = 1.minutes)
}
}
}
Rate Limit Response
When a client exceeds the rate limit, they receive a 429 Too Many Requests response with a Retry-After header.
Rate Limiting Strategy
| Endpoint | Limit | Why |
|---|---|---|
| General API | 60/min | Prevents scraping |
| Auth (login/register) | 10/min | Prevents brute-force |
| File upload | 5/min | Prevents storage abuse |
Security Headers
Security headers tell browsers how to handle your content. They prevent common attacks.
fun Application.configureSecurityHeaders() {
install(DefaultHeaders) {
// Prevent clickjacking - don't allow your site in iframes
header("X-Frame-Options", "DENY")
// Prevent MIME type sniffing
header("X-Content-Type-Options", "nosniff")
// XSS protection
header("X-XSS-Protection", "1; mode=block")
// Referrer policy
header("Referrer-Policy", "strict-origin-when-cross-origin")
// Content Security Policy
header("Content-Security-Policy", "default-src 'self'")
// Strict Transport Security (HTTPS)
header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
}
What Each Header Does
X-Frame-Options: DENY — Prevents your pages from being embedded in iframes. This stops clickjacking attacks where an attacker overlays your site with invisible elements.
X-Content-Type-Options: nosniff — Prevents the browser from guessing the content type. Without this, a browser might execute a file as JavaScript even if the server says it is a text file.
X-XSS-Protection: 1; mode=block — Enables the browser’s built-in XSS filter. If an XSS attack is detected, the browser blocks the page instead of sanitizing it.
Content-Security-Policy: default-src ‘self’ — Only allows resources from your own domain. This prevents inline scripts and external scripts from running.
Strict-Transport-Security — Tells browsers to always use HTTPS. After the first visit, the browser will never send an HTTP request to your domain.
Install Everything
Add all three security features to your application module:
fun Application.module() {
configureDatabase()
configureSerialization()
configureAuthentication()
configureCors() // New
configureRateLimit() // New
configureSecurityHeaders() // New
configureStatusPages()
configureRouting()
}
The order matters. CORS must be installed before routing so that preflight requests are handled correctly.
Input Validation
Security headers protect against browser-based attacks. But you also need to validate input on the server.
Request Size Limits
Prevent denial-of-service attacks by limiting request size:
install(ContentNegotiation) {
json(Json {
// Limit JSON parsing
isLenient = false
})
}
SQL Injection Prevention
Exposed ORM uses parameterized queries by default. This means SQL injection is not possible when you use Exposed correctly:
// Safe - Exposed uses parameterized queries
Users.selectAll().where { Users.email eq email }
// Dangerous - raw SQL with string interpolation
// NEVER do this
// exec("SELECT * FROM users WHERE email = '$email'")
Path Parameter Validation
Always validate path parameters:
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid ID", 400))
return@get
}
HTTPS Configuration
Ktor can serve HTTPS directly, but in production you should use a reverse proxy (Nginx, Caddy) for TLS termination:
Client → HTTPS → Nginx → HTTP → Ktor (port 8080)
This is the standard pattern because:
- Nginx handles TLS certificates (Let’s Encrypt)
- Nginx handles HTTP/2
- Ktor focuses on application logic
API Key Authentication
For service-to-service communication, you can use API keys instead of JWT:
// Simple API key check in a route
get("/api/internal/stats") {
val apiKey = call.request.headers["X-API-Key"]
if (apiKey != System.getenv("INTERNAL_API_KEY")) {
call.respond(HttpStatusCode.Unauthorized,
ErrorResponse("Invalid API key", 401))
return@get
}
// Return stats
}
Security Checklist
Before deploying to production, check:
- CORS allows only your frontend domain (not
anyHost()) - Rate limiting is enabled on auth endpoints
- Security headers are set
- Passwords are hashed with bcrypt
- JWT secrets are in environment variables
- HTTPS is configured (via reverse proxy)
- Input validation on all endpoints
- File upload size limits
- SQL injection prevention (use Exposed, not raw SQL)
Common Mistakes
- Using
anyHost()in production — Always specify allowed origins. - No rate limiting on login — Allows brute-force password attacks.
- Too restrictive CSP — Can break legitimate features. Test thoroughly.
- Forgetting request size limits — Attackers can send huge payloads to crash your server.
Source Code
You can find the source code for this tutorial on GitHub:
github.com/kemalcodes/ktor-tutorial — Branch: tutorial-14-security
What’s Next?
Your API is now secured with CORS, rate limiting, and security headers. In the next tutorial, you will add WebSockets for Real-Time Communication — building a chat server with rooms and broadcasting.
Related Articles
- Ktor Tutorial #12: Registration and Login Flow — Auth endpoints
- Ktor Tutorial #11: JWT Authentication — Token-based auth
- Ktor Tutorial #9: File Uploads — File security
- Kotlin Tutorial: Complete Series — Learn Kotlin from scratch