Axum Form Data and File Uploads Handled Safely Step by Step
Handle HTML forms and multipart file uploads in Axum 0.8: Form, Multipart, size limits, content type checks, safe filenames and streaming to disk.

Two kinds of input still arrive as non-JSON: HTML forms from a browser and files from an upload widget. Axum handles both with an extractor, and both are easy to write in a way that works in the demo and fails on the first hostile request. The form side has one surprise, the checkbox, that breaks the obvious struct. The upload side has several real risks: the filename is attacker-controlled, the content type is a claim and the size is whatever the client says until you stop reading. This post builds both endpoints, exercises them with curl and shows the responses, including the ones for files that should be refused. Tested on axum 0.8.9 (with the multipart feature) and tokio 1 on Rust 1.99.
Forms with Form
use axum::{Form, Router, routing::post};
use serde::Deserialize;
#[derive(Deserialize)]
struct Signup {
email: String,
#[serde(default)]
newsletter: bool,
}
async fn signup(Form(f): Form<Signup>) -> String {
format!("signup {} newsletter={}", f.email, f.newsletter)
}curl -s -X POST localhost:3000/signup -d 'email=ada%40example.test&newsletter=true'
curl -s -X POST localhost:3000/signup -d 'email=ada%40example.test'
curl -s -X POST localhost:3000/signup -d 'email=a%40b.test&newsletter=on'
curl -s -X POST localhost:3000/signup -d 'newsletter=true'
curl -s -X POST localhost:3000/signup -H 'content-type: application/json' -d '{}'signup [email protected] newsletter=true
signup [email protected] newsletter=false
Failed to deserialize form body: newsletter: provided string was not `true` or `false` [422]
Failed to deserialize form body: missing field `email` [422]
Form requests must have `Content-Type: application/x-www-form-urlencoded` [415]curl -d sends the urlencoded content type by default, which is why the first call just works. The third request is the one that bites real projects. A browser submits a checked checkbox as newsletter=on (the default value of the attribute) and omits an unchecked one entirely, so the missing case is covered by #[serde(default)], while on is neither true nor false and is rejected with a 422 your users would see as "the form is broken". Either set value="true" on the checkbox in HTML, or accept the browser's convention in the type:
use serde::{Deserialize, Deserializer};
fn checkbox<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
let s = String::deserialize(d)?;
Ok(matches!(s.as_str(), "on" | "true" | "1"))
}
#[derive(Deserialize)]
struct Signup {
email: String,
#[serde(default, deserialize_with = "checkbox")]
newsletter: bool,
}Forms are also where cross-site request forgery lives: a form post carries the browser's cookies automatically, so a session-authenticated form endpoint needs a CSRF defense that a JSON API with a bearer header does not. That is covered in session cookies and CSRF protection. Validation of the parsed values follows the same rules as path and query parameters.
Uploads with Multipart
A file upload arrives as multipart/form-data, a body made of several parts, each with its own headers. The Multipart extractor, behind the multipart cargo feature, gives you the parts one at a time through next_field. Because a part can be large, the right shape is a loop that reads each file field in chunks and writes them straight to disk, so memory use does not grow with file size:
use std::path::PathBuf;
use axum::{
Router,
extract::{DefaultBodyLimit, Multipart},
http::StatusCode,
routing::post,
};
use tokio::{fs::File, io::AsyncWriteExt};
const MAX_FILE: usize = 5 * 1024 * 1024;
async fn upload(mut multipart: Multipart) -> Result<String, (StatusCode, String)> {
let dir = PathBuf::from("/tmp/uploads");
tokio::fs::create_dir_all(&dir).await.map_err(internal)?;
let mut saved = Vec::new();
while let Some(mut field) = multipart.next_field().await.map_err(bad)? {
if field.name() != Some("file") {
continue;
}
let ext = match field.content_type() {
Some("image/png") => "png",
Some("image/jpeg") => "jpg",
other => {
return Err((StatusCode::UNSUPPORTED_MEDIA_TYPE, format!("type {other:?} not allowed")));
}
};
let stored = format!("{}.{ext}", unique_name());
let path = dir.join(&stored);
let mut out = File::create(&path).await.map_err(internal)?;
let mut size = 0usize;
while let Some(chunk) = field.chunk().await.map_err(bad)? {
size += chunk.len();
if size > MAX_FILE {
drop(out);
let _ = tokio::fs::remove_file(&path).await;
return Err((StatusCode::PAYLOAD_TOO_LARGE, "file too large".into()));
}
out.write_all(&chunk).await.map_err(internal)?;
}
saved.push(format!("{stored} ({size} bytes)"));
}
Ok(saved.join(", "))
}
fn unique_name() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let n = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
format!("{n:x}")
}
fn bad(e: axum::extract::multipart::MultipartError) -> (StatusCode, String) {
(e.status(), e.body_text())
}
fn internal(e: std::io::Error) -> (StatusCode, String) {
eprintln!("io error: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "storage error".into())
}
#[tokio::main]
async fn main() {
let app = Router::new().route(
"/upload",
post(upload).layer(DefaultBodyLimit::max(6 * 1024 * 1024)),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}head -c 3000 /dev/urandom > a.png
curl -s -F '[email protected];type=image/png' localhost:3000/upload
echo text > a.txt
curl -s -w ' [%{http_code}]\n' -F '[email protected];type=text/plain' localhost:3000/upload
head -c 5500000 /dev/urandom > big.png
curl -s -w ' [%{http_code}]\n' -F '[email protected];type=image/png' localhost:3000/upload18e6c2cc9bc4db80.png (3000 bytes)
type Some("text/plain") not allowed [415]
file too large [413]The four things the client controls
Everything in a multipart part is attacker-chosen, and the code above ignores or neutralizes each piece.
The filename is not used at all. field.file_name() returns whatever the client wrote, and ../../etc/cron.d/job or C:/Windows/x are both valid strings. Joining it onto a directory is the classic path traversal bug, so the handler generates its own stored name and keeps the extension from an allow list. If you need to show the original name back to the user, store it as data in a database column and escape it on output; it never becomes part of a path. The checks for safe joins are in safe path joins.
The content type on the part is a header the client set, so it proves nothing about the bytes. The allow list above stops an honest mistake (a text file) and nothing more: a script uploaded with type=image/png sails through. If the stored file is later served back, serve it with a fixed Content-Type from your own table, add X-Content-Type-Options: nosniff, and for images consider decoding and re-encoding them, which discards anything that is not pixels. Sniffing the first bytes (the PNG signature 89 50 4E 47) is a cheap extra check; the infer crate does it for common formats. Headers that make browsers treat uploads safely are in security headers.
The size is whatever the client sends until you stop reading. Two limits work together. DefaultBodyLimit::max(6 * 1024 * 1024) caps the whole request at 6 MB, applied to the route and not globally, so the JSON endpoints elsewhere keep a small default. Inside the loop, the byte counter enforces the per-file limit of 5 MB and does something the global limit cannot: it deletes the partial file when it trips, so a refused upload leaves nothing behind. In the test, the 5.5 MB file was refused partway through and the directory contained only the first, accepted file. Without the loop check, a 5.9 MB file would be stored although your limit was 5 MB, because the request cap is larger than the file cap by design to leave room for the other fields. Multipart parsing is also slow to complete if a client trickles bytes, so pair this with the request timeout in timeouts and body size caps.
The number of parts is unbounded too. A request with ten thousand tiny fields costs CPU even if each is small. The while let loop above reads every field; add a counter and return 400 after a reasonable number (a form with a few fields and one file has no business sending more than, say, 20).
Where uploads should end up
Writing to the local disk of a web server is fine for one machine and wrong for two: the next request lands on a server that does not have the file. Stream to object storage (S3 or compatible) with a multipart upload API, or hand the client a presigned URL and let the browser upload directly, which removes your server from the data path entirely and removes the body-limit and timeout concerns with it. For user-uploaded content that is later displayed, store it outside the document root, serve it from a separate domain without cookies, and scan it if your users can share files with each other. The handling of the files after upload, with a template to show them, is in static files and templates.
Reading the whole field into memory with field.bytes().await is simpler and acceptable for tiny files, such as a 100 KB avatar. Know the cost: ten concurrent 5 MB uploads hold 50 MB in RAM, and the limit and the number of concurrent requests are what bound that. Stream when files are large or many requests may be in flight.
Testing an upload
You can post a multipart body in a test without a socket, building the body by hand with a boundary, as in testing with Tower oneshot. Assert on the three outcomes that matter: the accepted file exists with the generated name, the wrong type returns 415, and an oversized file returns 413 and leaves no file behind. The last assertion is the one that catches the half-written-file bug.
When a plain form post is enough
If your page has a text field and a button, use Form and nothing else; multipart is only for files. If your clients are not browsers, accept JSON and upload files separately. And if the files can be large (video, archives), do not accept them through your application at all: presign a direct upload, then let a background job process the stored object. The overall arrangement of such a service is in an Axum production service.


