Most APIs need to handle files. Profile pictures, document uploads, image galleries — file handling is a common requirement.

In this tutorial, you will learn how to serve static files, handle file uploads via multipart form data, validate uploads, and protect against common security issues.

Serving Static Files

Ktor can serve static files from your resources directory or from the filesystem.

From Resources

Put files in src/main/resources/static/:

src/main/resources/static/
├── index.html
├── style.css
└── logo.png

Then configure the route:

routing {
    staticResources("/static", "static")
}

Now http://localhost:8080/static/index.html serves your file. Ktor sets the correct Content-Type header based on the file extension.

From the Filesystem

To serve files from a directory on disk (useful for uploaded files):

routing {
    staticFiles("/files", File("uploads"))
}

This serves files from the uploads/ directory at /files/{filename}.

Index Files

You can set a default file for directories:

staticResources("/static", "static") {
    default("index.html")
}

Now /static/ serves index.html automatically.

File Uploads with Multipart Form Data

When a client uploads a file, it sends a multipart/form-data request. This is different from JSON — the request contains multiple “parts”, each with its own content type.

Basic Upload Handler

post("/api/upload") {
    val multipart = call.receiveMultipart()

    multipart.forEachPart { part ->
        when (part) {
            is PartData.FileItem -> {
                val fileName = part.originalFileName ?: "unknown"
                val inputStream = part.streamProvider()
                val bytes = inputStream.readBytes()
                inputStream.close()

                // Save the file
                File("uploads/$fileName").writeBytes(bytes)
            }
            is PartData.FormItem -> {
                // Text form fields
                val fieldName = part.name
                val value = part.value
            }
            else -> {}
        }
        part.dispose()
    }

    call.respondText("File uploaded", status = HttpStatusCode.Created)
}

Understanding Multipart Parts

Part TypeWhat It ContainsExample
FileItemBinary file dataImage, PDF, document
FormItemText field valueDescription, tags
BinaryItemRaw binary dataLess common
BinaryChannelItemStreaming binary dataLarge files

Upload Response Model

Return useful information about the uploaded file:

@Serializable
data class UploadResponse(
    val fileName: String,
    val originalName: String,
    val size: Long,
    val url: String
)

Validation and Security

File uploads need careful validation. Never trust user input.

1. File Extension Validation

Only allow specific file types:

private val ALLOWED_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "pdf", "txt")

val extension = originalName.substringAfterLast(".", "")
if (extension.lowercase() !in ALLOWED_EXTENSIONS) {
    call.respond(
        HttpStatusCode.BadRequest,
        ErrorResponse("File type .$extension is not allowed", 400)
    )
    return@forEachPart
}

2. File Size Validation

Prevent huge uploads from crashing your server:

private const val MAX_FILE_SIZE = 10 * 1024 * 1024L  // 10 MB

if (bytes.size > MAX_FILE_SIZE) {
    call.respond(
        HttpStatusCode.BadRequest,
        ErrorResponse("File too large. Max size is 10 MB", 400)
    )
    return@forEachPart
}

3. Unique File Names

Never use the original file name directly. Generate a unique name to prevent conflicts and security issues:

val fileName = "${UUID.randomUUID()}.$extension"

This produces file names like a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg.

4. Path Traversal Protection

Prevent attackers from accessing files outside the uploads directory:

get("/api/files/{fileName}") {
    val fileName = call.parameters["fileName"] ?: return@get

    // Block path traversal attempts
    if (fileName.contains("..") || fileName.contains("/")) {
        call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid file name", 400))
        return@get
    }

    val file = File(uploadsDir, fileName)
    if (!file.exists()) {
        call.respond(HttpStatusCode.NotFound, ErrorResponse("File not found", 404))
        return@get
    }

    call.respondFile(file)
}

Without this check, an attacker could request /api/files/../../etc/passwd to read system files.

Complete File Routes

Here is the complete file with all routes:

fun Route.fileRoutes() {
    val uploadsDir = File("uploads").also { it.mkdirs() }

    // Upload a file
    post("/upload") {
        val multipart = call.receiveMultipart()
        var uploadResponse: UploadResponse? = null

        multipart.forEachPart { part ->
            when (part) {
                is PartData.FileItem -> {
                    val originalName = part.originalFileName ?: "unknown"
                    val extension = originalName.substringAfterLast(".", "")

                    if (extension.lowercase() !in ALLOWED_EXTENSIONS) {
                        call.respond(HttpStatusCode.BadRequest,
                            ErrorResponse("File type not allowed", 400))
                        part.dispose()
                        return@forEachPart
                    }

                    val fileName = "${UUID.randomUUID()}.$extension"
                    val inputStream = part.streamProvider()
                    val bytes = inputStream.readBytes()
                    inputStream.close()

                    if (bytes.size > MAX_FILE_SIZE) {
                        call.respond(HttpStatusCode.BadRequest,
                            ErrorResponse("File too large", 400))
                        part.dispose()
                        return@forEachPart
                    }

                    File(uploadsDir, fileName).writeBytes(bytes)

                    uploadResponse = UploadResponse(
                        fileName = fileName,
                        originalName = originalName,
                        size = bytes.size.toLong(),
                        url = "/api/files/$fileName"
                    )
                }
                else -> {}
            }
            part.dispose()
        }

        if (uploadResponse != null) {
            call.respond(HttpStatusCode.Created, uploadResponse!!)
        } else {
            call.respond(HttpStatusCode.BadRequest,
                ErrorResponse("No file provided", 400))
        }
    }

    // Download a file
    get("/files/{fileName}") {
        val fileName = call.parameters["fileName"] ?: return@get
        if (fileName.contains("..") || fileName.contains("/")) {
            call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid name", 400))
            return@get
        }
        val file = File(uploadsDir, fileName)
        if (!file.exists()) {
            call.respond(HttpStatusCode.NotFound, ErrorResponse("Not found", 404))
            return@get
        }
        call.respondFile(file)
    }

    // Delete a file
    delete("/files/{fileName}") {
        val fileName = call.parameters["fileName"] ?: return@delete
        if (fileName.contains("..") || fileName.contains("/")) {
            call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid name", 400))
            return@delete
        }
        val file = File(uploadsDir, fileName)
        if (!file.exists()) {
            call.respond(HttpStatusCode.NotFound, ErrorResponse("Not found", 404))
            return@delete
        }
        file.delete()
        call.respond(HttpStatusCode.NoContent)
    }
}

Testing File Uploads

Use Ktor’s MultiPartFormDataContent to send files in tests:

@Test
fun `upload and download file`() = testApplication {
    application { module() }
    val client = jsonClient()

    val uploadResponse = client.post("/api/upload") {
        setBody(MultiPartFormDataContent(
            formData {
                append("file", "Hello, World!".toByteArray(), Headers.build {
                    append(HttpHeaders.ContentDisposition, "filename=\"test.txt\"")
                })
            }
        ))
    }
    assertEquals(HttpStatusCode.Created, uploadResponse.status)
    val uploaded = uploadResponse.body<UploadResponse>()
    assertEquals("test.txt", uploaded.originalName)

    val downloadResponse = client.get(uploaded.url)
    assertEquals(HttpStatusCode.OK, downloadResponse.status)
    assertEquals("Hello, World!", downloadResponse.bodyAsText())
}

Testing with curl

# Upload a file
curl -X POST http://localhost:8080/api/upload \
  -F "file=@photo.jpg"

# Download the file
curl http://localhost:8080/api/files/{fileName}

# Delete the file
curl -X DELETE http://localhost:8080/api/files/{fileName}

Multiple File Uploads

To upload multiple files in a single request, loop through all file parts:

post("/upload/multiple") {
    val multipart = call.receiveMultipart()
    val uploadedFiles = mutableListOf<UploadResponse>()

    multipart.forEachPart { part ->
        when (part) {
            is PartData.FileItem -> {
                val originalName = part.originalFileName ?: "unknown"
                val extension = originalName.substringAfterLast(".", "")

                if (extension.lowercase() in ALLOWED_EXTENSIONS) {
                    val fileName = "${UUID.randomUUID()}.$extension"
                    val inputStream = part.streamProvider()
                    val bytes = inputStream.readBytes()
                    inputStream.close()

                    if (bytes.size <= MAX_FILE_SIZE) {
                        File(uploadsDir, fileName).writeBytes(bytes)
                        uploadedFiles.add(UploadResponse(
                            fileName = fileName,
                            originalName = originalName,
                            size = bytes.size.toLong(),
                            url = "/api/files/$fileName"
                        ))
                    }
                }
            }
            else -> {}
        }
        part.dispose()
    }

    if (uploadedFiles.isNotEmpty()) {
        call.respond(HttpStatusCode.Created, uploadedFiles)
    } else {
        call.respond(HttpStatusCode.BadRequest,
            ErrorResponse("No valid files provided", 400))
    }
}

Content Type Detection

Ktor automatically sets the Content-Type header when serving files based on the file extension. But you can also set it manually:

get("/files/{fileName}") {
    val file = File(uploadsDir, fileName)
    val contentType = when (file.extension.lowercase()) {
        "jpg", "jpeg" -> ContentType.Image.JPEG
        "png" -> ContentType.Image.PNG
        "gif" -> ContentType.Image.GIF
        "pdf" -> ContentType("application", "pdf")
        "txt" -> ContentType.Text.Plain
        else -> ContentType.Application.OctetStream
    }
    call.respondFile(file)
}

File Upload with Form Fields

Often you want to upload a file along with metadata (like a description or category). Handle both FileItem and FormItem parts:

post("/upload") {
    val multipart = call.receiveMultipart()
    var description = ""
    var uploadResponse: UploadResponse? = null

    multipart.forEachPart { part ->
        when (part) {
            is PartData.FormItem -> {
                if (part.name == "description") {
                    description = part.value
                }
            }
            is PartData.FileItem -> {
                // Handle file upload (same as before)
            }
            else -> {}
        }
        part.dispose()
    }
}

The client sends this as:

curl -X POST http://localhost:8080/api/upload \
  -F "file=@photo.jpg" \
  -F "description=Profile picture"

Cleanup Strategy

Uploaded files can accumulate over time. Consider adding a cleanup job:

// Delete files older than 30 days
fun cleanupOldFiles() {
    val uploadsDir = File("uploads")
    val thirtyDaysAgo = System.currentTimeMillis() - (30L * 24 * 60 * 60 * 1000)

    uploadsDir.listFiles()?.forEach { file ->
        if (file.lastModified() < thirtyDaysAgo) {
            file.delete()
        }
    }
}

You could run this as a scheduled task or a background coroutine.

Storage Strategies

For a production application, consider these storage options:

StrategyProsCons
Local filesystemSimple, fastDoes not scale, single server only
AWS S3 / GCSScalable, CDN supportExternal dependency, costs
Database (BLOB)Transactional, backup includedSlow for large files, database bloat

For this tutorial, we use local storage. In production, you would typically upload to S3 or similar cloud storage.

Source Code

You can find the source code for this tutorial on GitHub:

github.com/kemalcodes/ktor-tutorial — Branch: tutorial-09-files

What’s Next?

In the next tutorial, we will add database migrations with Flyway. You will learn why migrations matter, how to write them, and how to manage your database schema over time.

Ktor Tutorial #10: Database Migrations with Flyway