Writing a Small S3 Proxy in Rust
I kept running into the same small annoyance: I had a handful of S3-compatible buckets spread across different accounts and services — some on AWS, some on a MinIO box — and every client that wanted to read or write needed its own set of credentials, its own region, its own endpoint. What I really wanted was a single front door: one URL, one simple key, and a server in the middle that figured out where each request should actually go and whether the caller was allowed to make it. That became s3-proxy, a thin S3-compatible gateway I wrote in Rust.
The important word is thin. It’s not a cache and it’s not an aggregator — every bucket name maps to exactly one backend account. The proxy’s whole job is to authenticate you, decide whether you’re allowed to touch the bucket you asked for, and forward the request to the right place. Everything interesting is in those three steps.
Routing by bucket name
The configuration is built around two maps: accounts (each with an endpoint, region, credentials, and the list of buckets it owns) and users (each with an API key, a role, and a list of allowed buckets). Picking a backend for a request is then just a search for the account that claims the bucket in the URL:
pub fn find_account_for_bucket(&self, bucket: &str) -> Option<(&String, &AccountConfig)> {
self.accounts.iter().find(|(_, account)| {
account.buckets.contains(&bucket.to_string())
})
}
At startup I build one aws-sdk-s3 client per account — each pinned to that account’s endpoint and credentials — and stash them in a HashMap keyed by account id. A request comes in for /photos/cat.jpg, the proxy looks up which account owns photos, grabs that account’s pre-built client from the map, and the official AWS SDK does all the actual S3 talking. I was happy to let the SDK own SigV4 signing, retries, and pagination rather than reinventing any of it.
Auth that isn’t SigV4
Real S3 authenticates every request with SigV4, which is powerful and also a lot of ceremony. For a private gateway I wanted something blunter, so clients send a single x-api-key header and nothing else. All of the policy lives in one axum middleware that runs before any handler:
// look up the user by their API key, reject if unknown
let (username, user) = match config.find_user_by_api_key(api_key) {
Some(u) => u,
None => return AppError::Unauthorized("Invalid API key".into()).into_response(),
};
From there the rules are deliberately small. Each user has a role — admin and user can write, readonly can only GET — and an allow-list of buckets, where ["*"] means “anything the proxy knows about.” The handlers call check_bucket_access and, on uploads, check_write_permission, so an unauthorized request gets turned away before a single byte reaches a backend. One detail I’m glad I added early: the x-api-key header is redacted in the request logs, so the access logs never quietly become a list of valid credentials.
A sliding-window rate limiter in a few lines
To keep one noisy client from hammering the backends, each user gets a simple per-minute budget. The limiter keeps a timestamp for every recent request, drops the ones that have aged out of the window, and checks what’s left:
let window = Duration::from_secs(60);
let max_requests = 100;
let requests = self.requests.entry(username.to_string()).or_default();
requests.retain(|&t| now.duration_since(t) < window); // forget old hits
if requests.len() >= max_requests {
return true; // limited
}
requests.push(now);
It’s a true sliding window rather than a fixed bucket that resets on the minute, so you can’t sneak a double-burst across a reset boundary. I’ll be honest about the trade-off, though: the state is an in-process HashMap behind a lock. That’s perfect for a single instance and useless the moment you run two of them — at that point the counter belongs in something shared like Redis. For what this proxy is, one node was the right call.
Saying no to bad paths
Because the proxy turns URL path segments into bucket and key names, the validator is fussy about shape on purpose. A request has to be either /{bucket} or /{bucket}/{key} — anything with extra segments, .., doubled slashes, or a trailing slash gets a 400 before it’s allowed to mean anything to a backend. PUTs additionally get their Content-Length checked against a configurable maximum so an oversize upload is rejected up front rather than after it’s streamed in. On the way back out, every response picks up the usual hardening headers — X-Content-Type-Options: nosniff, X-Frame-Options: DENY, HSTS — injected in that same middleware.
The handlers are mostly plumbing
Once auth and routing are out of the way, the request handlers are refreshingly boring, which is exactly what you want from a proxy. GET /{bucket}/{key} streams the object back from the backend; PUT wraps the request body in a ByteStream and hands it to the SDK verbatim, preserving the content type; GET /{bucket} pages through list_objects_v2 with continuation tokens and renders a small ListBucketResult XML document so ordinary S3 tooling sees what it expects. Errors come back as JSON with a matching status code, except for listings, which stay XML to keep clients happy.
If I were to keep pushing on it, the obvious next step is end-to-end streaming on the read path — the current GET handler collects the object into memory before sending it, which is fine for small files and wrong for large ones. The rate limiter would move to a shared store, and I’d probably add real metrics. But the core turned out to be a satisfying example of how far you get in Rust by leaning on good libraries: axum for the HTTP surface, the AWS SDK for the hard S3 parts, and a couple hundred lines of my own code to glue routing, identity, and policy together.
The source is on GitHub at github.com/arazmj/s3-proxy if you want to read the rest.