Skip to content
rusthacks.dev
Web

File Uploads to S3 From a Rust Service With Presigned URLs

A presigned URL lets the browser upload straight to S3, so your service never touches the bytes. The server signs, the client uploads, and a confirmation step records the result.

15 min read
Summarize in AI
Flat illustration for the post “File Uploads to S3 From a Rust Service With Presigned URLs”, composed around wasm.

The obvious way to accept a file upload is to let the browser post it to your server and have the server forward it to S3. It works, and it costs you three things. Every byte crosses your network twice. A worker is held for the whole transfer, which on a phone connection can be minutes. And the memory or disk to buffer the file has to exist on a machine whose real job is answering API requests.

A presigned URL removes the middleman. Your service signs a short-lived URL that grants permission to upload exactly one object, hands it to the client, and the client talks to S3 directly. Your service handles only two tiny JSON requests per upload. This guide builds that flow end to end with aws-sdk-s3 1.157, Axum 0.8.9 and Tokio 1.54 on Rust 1.99, and spends most of its time on the part that goes wrong in production: what the URL does and does not protect you from.

The flow in five steps

  1. The client calls POST /uploads with the content type and size of the file.
  2. The server validates both, chooses an object key, and returns a presigned PUT URL.
  3. The client sends the file bytes straight to that URL with the same content type.
  4. The client calls POST /uploads/{id}/complete.
  5. The server asks S3 about the object with head_object, checks it, and only then records the upload as real.

Step 5 is the one people skip, and it is the one that makes the design safe. A presigned URL proves your server allowed an upload. It does not prove one happened, and it does not prove the file is what the client said it was. Treat anything that arrived through the URL as untrusted input until you have looked at it.

What presigning is, and what it costs

Presigning is local arithmetic. The SDK takes your credentials, the HTTP method, the bucket, the key, the expiry and a set of headers, and computes a signature with the AWS Signature Version 4 algorithm. No request goes to S3. That has two consequences worth knowing. It is fast, microseconds rather than a network round trip, so you can presign in the request path without caching. And it cannot fail because S3 is slow, though it can produce a URL for a bucket that does not exist, which only fails when the client uses it.

The signature covers the method, the path, the query parameters and a list of signed headers. If the client changes any signed part, S3 recomputes a different signature and answers 403 SignatureDoesNotMatch. This is the mechanism we use to bind the content type and the size into the URL, and it is why the next function matters so much.

A presigning function that constrains the upload

The function below does four jobs. It checks the content type against an allow list, checks the declared size against a limit, generates the object key itself, and presigns with the content type and length bound into the signature. It runs without any network access when you supply static credentials, so you can try it exactly as written.

src/main.rsrust
use std::time::Duration;

use aws_sdk_s3::presigning::PresigningConfig;
use aws_sdk_s3::Client;
use uuid::Uuid;

const MAX_BYTES: i64 = 10 * 1024 * 1024;
const ALLOWED: [(&str, &str); 3] = [("image/png", "png"), ("image/jpeg", "jpg"), ("application/pdf", "pdf")];

struct Ticket {
    key: String,
    url: String,
    content_type: String,
}

async fn presign_upload(client: &Client, bucket: &str, content_type: &str, size: i64) -> anyhow::Result<Ticket> {
    if !(1..=MAX_BYTES).contains(&size) {
        anyhow::bail!("size {size} is outside 1..={MAX_BYTES} bytes");
    }
    let Some((_, ext)) = ALLOWED.iter().find(|(ct, _)| *ct == content_type) else {
        anyhow::bail!("content type {content_type} is not allowed");
    };

    // The server picks the key. The client's file name never touches the path.
    let key = format!("uploads/pending/{}.{}", Uuid::new_v4(), ext);

    let presigned = client
        .put_object()
        .bucket(bucket)
        .key(&key)
        .content_type(content_type)
        .content_length(size)
        .presigned(PresigningConfig::expires_in(Duration::from_secs(300))?)
        .await?;

    Ok(Ticket { key, url: presigned.uri().to_string(), content_type: content_type.to_string() })
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let config = aws_config::defaults(aws_config::BehaviorVersion::latest()).load().await;
    let client = Client::new(&config);

    let ticket = presign_upload(&client, "example-uploads", "image/png", 482_113).await?;
    println!("key:  {}", ticket.key);
    println!("type: {}", ticket.content_type);
    let (base, query) = ticket.url.split_once('?').unwrap();
    println!("url:  {base}");
    for pair in query.split('&') {
        let name = pair.split('=').next().unwrap();
        if ["X-Amz-Expires", "X-Amz-SignedHeaders", "X-Amz-Algorithm"].contains(&name) {
            println!("  {pair}");
        }
    }

    match presign_upload(&client, "example-uploads", "image/png", 80_000_000).await {
        Err(e) => println!("rejected: {e}"),
        Ok(_) => unreachable!(),
    }
    match presign_upload(&client, "example-uploads", "text/html", 900).await {
        Err(e) => println!("rejected: {e}"),
        Ok(_) => unreachable!(),
    }
    Ok(())
}
Cargo.tomlTOML
[dependencies]
anyhow = "1"
aws-config = "1"
aws-sdk-s3 = "1"
tokio = { version = "1.54", features = ["full"] }
uuid = { version = "1", features = ["v4", "serde"] }

Running it with throwaway credentials (AWS_ACCESS_KEY_ID=AKIDEXAMPLE AWS_SECRET_ACCESS_KEY=change-me AWS_REGION=us-east-1 cargo run) prints the following. The UUID differs on every run.

Text
key:  uploads/pending/0fe32e23-f1aa-4832-a543-bee66b3697b5.png
type: image/png
url:  https://example-uploads.s3.us-east-1.amazonaws.com/uploads/pending/0fe32e23-f1aa-4832-a543-bee66b3697b5.png
  X-Amz-Algorithm=AWS4-HMAC-SHA256
  X-Amz-Expires=300
  X-Amz-SignedHeaders=content-length%3Bcontent-type%3Bhost
rejected: size 80000000 is outside 1..=10485760 bytes
rejected: content type text/html is not allowed

The X-Amz-SignedHeaders line is the proof that the constraints are in the signature: content-length and content-type are both signed. A client that tries to upload 80 MB against a URL signed for 482,113 bytes sends a request whose headers no longer match, and S3 refuses it. That is the answer to the common claim that a presigned PUT cannot limit size. It can pin the size exactly, because you require the client to declare it up front. What it cannot do is express a range ("anything under 10 MB"); for that you need a presigned POST policy, covered below.

Why the server picks the key

The key is uploads/pending/{uuid}.{ext}, built entirely from values the server controls. The client's file name never reaches the path, which closes three problems at once. A name like ../../config.json cannot escape the prefix. Two users uploading avatar.png cannot overwrite each other. And a name containing a newline, a null byte or a right-to-left override cannot confuse a log or a later listing. Keep the original name in your database for display and for the Content-Disposition header on download, and never in the object key.

The extension comes from the allow list, not from the file name. That matters because a browser decides how to treat a download from the content type and the extension, and evil.html served with a guessable type is the beginning of a stored cross-site scripting bug. An allow list of three types is dull and effective; widen it only when a feature needs it.

The two endpoints

With presigning in a function, the HTTP layer is thin. The handlers below run in Axum 0.8 (note the {id} path syntax, which replaced :id in 0.8) and share an AppState holding the client and bucket name.

src/uploads.rsrust
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::{Json, Router, routing::post};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct StartUpload {
    content_type: String,
    size: i64,
}

#[derive(Serialize)]
struct StartedUpload {
    id: uuid::Uuid,
    url: String,
    content_type: String,
}

async fn start_upload(
    State(app): State<AppState>,
    Json(req): Json<StartUpload>,
) -> Result<Json<StartedUpload>, (StatusCode, String)> {
    let ticket = presign_upload(&app.s3, &app.bucket, &req.content_type, req.size)
        .await
        .map_err(|e| (StatusCode::UNPROCESSABLE_ENTITY, e.to_string()))?;

    // Insert (id, key, declared size, content type, status = 'pending') here.
    let id = uuid::Uuid::new_v4();
    Ok(Json(StartedUpload { id, url: ticket.url, content_type: ticket.content_type }))
}

pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/uploads", post(start_upload))
        .route("/uploads/{id}/complete", post(complete_upload))
}

The row you insert in start_upload is the other half of the design. It records the key, the declared size and the content type with a status of pending, so the completion step reads the key from your database instead of trusting anything the client sends back. A client that could pass its own key to /complete could mark any object in the bucket as its upload.

Completion verifies before it trusts

src/complete.rsrust
async fn complete_upload(
    State(app): State<AppState>,
    Path(id): Path<uuid::Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
    // Load the pending row by id; its key and declared size come from the database.
    let (key, declared_size) = load_pending(&app.db, id).await?;

    let head = app
        .s3
        .head_object()
        .bucket(&*app.bucket)
        .key(&key)
        .send()
        .await
        .map_err(|_| (StatusCode::CONFLICT, "object has not been uploaded".to_string()))?;

    let actual = head.content_length().unwrap_or(0);
    if actual != declared_size {
        let _ = app.s3.delete_object().bucket(&*app.bucket).key(&key).send().await;
        return Err((StatusCode::UNPROCESSABLE_ENTITY, "size does not match what was declared".into()));
    }

    mark_complete(&app.db, id).await?;
    Ok(StatusCode::NO_CONTENT)
}

head_object returns the size, the content type and the entity tag without downloading the body. Comparing the real size to the declared one is a cheap second line of defense behind the signed header. Deleting a mismatched object immediately keeps your bucket from filling with files no row refers to. The first three lines of the handler need a real database; the S3 half compiles against aws-sdk-s3 1.157 as written.

A client that calls /complete before the upload finishes gets a 409, which it can retry. A client that never calls it leaves a pending row and an object under uploads/pending/, and that is what the cleanup rules below are for.

What goes wrong in practice

Most failed uploads in production come from four causes, and every one shows up as a bare 403 in the browser console, so it is worth knowing them before you debug.

The first is a content type mismatch. The signature covers content-type, so the browser must send exactly the value you signed, down to charset suffixes. If your JavaScript lets the browser derive the type from the File object while you signed image/png, a file with a different detected type fails. Send the type the server returned, always.

The second is missing CORS configuration. The browser sends a preflight OPTIONS request for a cross-origin PUT with a custom header, and the bucket must allow it. A working rule:

cors.jsonJSON
[
  {
    "AllowedOrigins": ["https://app.example.test"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["content-type", "content-length"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

Apply it with aws s3api put-bucket-cors --bucket example-uploads --cors-configuration file://cors.json. List only your real origins. A * origin lets any site upload through a URL it obtains, which on its own is harmless but is a bad habit to teach a codebase.

The third is clock skew. The signature embeds a timestamp, and S3 rejects a request whose signing time is too far from its own clock, but the expiry is measured from the signing time on your server. A server whose clock is ten minutes slow produces URLs that are already dead on a five-minute expiry, which looks like a flaky failure that only affects one host. Run NTP, and check date before suspecting S3.

The fourth is a proxy or corporate gateway rewriting a header. Anything that changes content-type or adds a header listed in the signature breaks the upload. The error is the same SignatureDoesNotMatch, and reproducing it with curl --upload-file from the same network is the quickest way to tell whether the cause is the client code or the path.

You can test the whole flow without a browser:

Shell
curl -s -X POST localhost:3000/uploads \
  -H 'content-type: application/json' \
  -d '{"content_type":"image/png","size":482113}'
# take "url" from the response, then:
curl -X PUT "$URL" -H 'content-type: image/png' --data-binary @avatar.png
curl -i -X POST localhost:3000/uploads/$ID/complete

The browser half

The server side is only useful if the client sends the right bytes with the right headers. This is the entire client, and the three details that matter are called out after it.

upload.jsJavaScript
export async function uploadFile(file) {
  const start = await fetch('/uploads', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ content_type: file.type, size: file.size }),
  });
  if (!start.ok) throw new Error(await start.text());
  const { id, url, content_type } = await start.json();

  const put = await fetch(url, {
    method: 'PUT',
    headers: { 'content-type': content_type },
    body: file,
  });
  if (!put.ok) throw new Error(`storage refused the upload: ${put.status}`);

  const done = await fetch(`/uploads/${id}/complete`, { method: 'POST' });
  if (!done.ok) throw new Error(await done.text());
  return id;
}

The first detail is that the PUT uses the content_type the server returned, not file.type. They are usually equal, and when they are not, the server's value is the one in the signature. The second is that the body is the File itself, which the browser streams from disk; there is no reason to read it into memory with arrayBuffer(). The third is that content-length is set by the browser from the body, so the declared size and the real size agree unless the file changed on disk between the two calls, which is an honest cause of a mismatch worth handling with a retry.

To show progress, fetch is the wrong tool, since it exposes no upload progress events. Use XMLHttpRequest with xhr.upload.onprogress for a progress bar, with the same URL, method and headers.

Serving the file back

Uploads are half the story. Because the bucket is private, downloads need a signed URL as well, and the same SDK signs get_object. The useful trick is overriding the response headers in the signature so you control how the browser treats the file whatever its stored metadata says.

src/download.rsrust
use std::time::Duration;
use aws_sdk_s3::presigning::PresigningConfig;

async fn presign_download(
    client: &aws_sdk_s3::Client,
    bucket: &str,
    key: &str,
    display_name: &str,
) -> anyhow::Result<String> {
    let presigned = client
        .get_object()
        .bucket(bucket)
        .key(key)
        .response_content_disposition(format!("attachment; filename=\"{}\"", display_name.replace('"', "")))
        .response_content_type("application/octet-stream")
        .presigned(PresigningConfig::expires_in(Duration::from_secs(60))?)
        .await?;
    Ok(presigned.uri().to_string())
}

response_content_disposition makes the browser download the file under the original name you kept in the database, and response_content_type of application/octet-stream prevents it from rendering anything inline. Together they neutralize the stored-XSS risk of serving user content from a domain that holds cookies: even an HTML file disguised as an image will be saved, not executed. The name is stripped of double quotes because it goes inside a quoted header value; a stricter version also removes control characters and slashes. A short expiry is fine here because the browser starts the request immediately after your API hands the URL over.

Expiry is checked at the start, not the end

A frequent worry is that a five-minute URL will kill a ten-minute upload. It will not. S3 validates the signature and its expiry when the request arrives, then lets the transfer run to completion. What the expiry bounds is how long a leaked URL can be used to begin a new upload. So pick it for the threat, not the file size: five minutes for an upload ticket, a minute or less for a download link generated on click, and longer only for links you deliberately mail to someone.

The corollary is that a URL is reusable until it expires. A client that uploads successfully can upload again to the same key with the same URL within the window and overwrite its own object. That is harmless when each key belongs to one pending upload and /complete is idempotent, and it is another reason to compare the final size at completion rather than trusting that the first upload was the only one.

Presigned POST for a size range

When you want to allow "any size up to 10 MB" without the client declaring an exact length, S3 offers presigned POST policies. The server signs a JSON policy containing conditions, among them ["content-length-range", 1, 10485760], and the client submits a multipart form. S3 evaluates the conditions on arrival and rejects an out-of-range upload with a 400, before the bytes are stored.

The Rust SDK does not generate POST policies for you, so you build the policy JSON, base64 it and sign it yourself with the aws-sigv4 crate, or use a small helper crate. That is more code and more ways to get the encoding wrong. The exact-length PUT above is simpler and, for a browser flow where the client knows the File.size, just as strong. Reach for POST policies when the final size is unknown at signing time, as with a streaming recorder, or when you want a range check enforced by S3 itself.

Multipart uploads for large files

A single PUT is limited to 5 GB, and a flaky connection makes anything over a few hundred megabytes miserable because one dropped packet restarts the transfer. For large files the client uses multipart upload: the server creates the multipart upload and presigns one URL per part with upload_part().part_number(n).upload_id(...), the client uploads parts in parallel and retries single parts, and the server finishes with complete_multipart_upload. The signing logic is the same per part; the extra work is bookkeeping the part ETags. If your files are mostly under 100 MB, skip it. If users upload video, it is not optional.

Cleaning up what never got completed

Abandoned uploads are inevitable: a closed tab, a lost connection, a client that never calls /complete. Without cleanup they accumulate forever and you pay for them.

  • Add an S3 lifecycle rule that expires objects under uploads/pending/ after one day. This catches objects with no row.
  • Run a scheduled job that deletes pending rows older than a day, and move completed uploads out of the pending prefix by copying to a final key, then deleting the original. Moving also gives you a place to hang a virus scan before the file becomes visible; run it from a background job queue.
  • Abort stale multipart uploads with a lifecycle rule too (AbortIncompleteMultipartUpload), since incomplete parts are billed and do not show in a normal listing.

Hardening checklist

  • Block all public access on the bucket. Serve downloads with presigned GET URLs, or through a CDN with origin access control.
  • Keep the expiry short. Five minutes is plenty for the start of an upload; the transfer itself can take longer, because expiry is checked when the request begins.
  • Treat the URL as a credential. Do not log it, put it in analytics or send it in error reports. Log the key instead.
  • Use a role with the narrowest policy: s3:PutObject on uploads/pending/* for the signing service, and nothing else. Presigned URLs inherit the signer's permissions, and a URL signed by an admin role can do whatever that role can, if the request is shaped right.
  • Scan content, not just names. An image upload is only an image if a decoder agrees; run the file through the same library you will use to serve it, and reject what fails to parse.

When a presigned URL is the wrong tool

If you must transform or inspect every byte before storing it, such as stripping metadata, enforcing a virus scan before any copy exists, or encrypting with a per-tenant key held only by your service, the server has to see the stream, and proxying is the honest design. Uploads under a few kilobytes, like an avatar after client-side resizing, are fine through your API and avoid a second round trip. And if your clients are not browsers but other servers on a private network, a direct authenticated put_object from the service that owns the file is simpler than signing anything. For the proxying case, see how to read a body safely in file upload security.

Taggeds3
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.