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

EndpointLimitWhy
General API60/minPrevents scraping
Auth (login/register)10/minPrevents brute-force
File upload5/minPrevents 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

  1. Using anyHost() in production — Always specify allowed origins.
  2. No rate limiting on login — Allows brute-force password attacks.
  3. Too restrictive CSP — Can break legitimate features. Test thoroughly.
  4. 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.