You’ve got a backend, a file to serve, and a user who needs to download it — without exposing your storage credentials, without routing 500 MB of video through your app server, and without writing a custom auth layer. AWS figured this out years ago with presigned URLs. The good news: MinIO implements the exact same mechanism, and you can run it on a $10 VPS.
This isn’t a "what is object storage" explainer. It’s a practical guide to generating presigned URLs on MinIO — for downloads, uploads, and everything in between — with production-grade patterns you can actually use.
Official MinIO repo: https://github.com/minio/minio
How Presigned URLs Actually Work
Before touching a single line of code, you need to understand the mechanism. A presigned URL is just a regular HTTP URL with a cryptographic signature baked into the query string. That signature proves the URL was created by someone who held valid credentials at the time of generation — without embedding those credentials in the URL itself.
The signing algorithm MinIO uses is AWS Signature Version 4 (SigV4). Here’s what it encodes:
- The HTTP method (
GET,PUT) - The bucket and object key
- The expiration timestamp
- The access key ID (but not the secret)
- A HMAC-SHA256 signature over all of the above
When your client hits that URL, MinIO recomputes the signature using its stored secret key and compares. If they match and X-Amz-Expires hasn’t elapsed — access granted. No session, no cookie, no token exchange.
The key insight: the URL is self-contained proof of authorization. Your app server generates it once, hands it to the client, and steps out of the data path entirely. That’s the whole value proposition.
Setting Up MinIO with Docker Compose
If you’re still running MinIO as a bare binary, stop. The Docker Compose setup gives you restart policies, environment isolation, and a reproducible config. Here’s a production-leaning setup:
# docker-compose.yml
version: "3.8"
services:
minio:
image: quay.io/minio/minio:latest
container_name: minio
restart: unless-stopped
command: server /data --console-address ":9001"
ports:
- "9000:9000" # S3 API
- "9001:9001" # Web console (restrict this in prod)
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
# Tell MinIO its public URL — critical for presigned URL generation
MINIO_SERVER_URL: "https://s3.yourdomain.com"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 10s
retries: 3
volumes:
minio_data:
And the .env file (never commit this):
MINIO_ROOT_USER=your_access_key
MINIO_ROOT_PASSWORD=your_super_secret_key_min_8_chars
Critical: Set MINIO_SERVER_URL to the public-facing URL of your MinIO instance. If you skip this and MinIO is behind a reverse proxy, generated presigned URLs will contain the internal container hostname — and they’ll be completely useless to anyone outside.
Your First Presigned URL (Python)
The boto3 library works against MinIO with zero modifications. Just point it at your server:
import boto3
from botocore.client import Config
# Initialize the S3 client pointing at MinIO
s3_client = boto3.client(
"s3",
endpoint_url="https://s3.yourdomain.com",
aws_access_key_id="your_access_key",
aws_secret_access_key="your_super_secret_key",
config=Config(signature_version="s3v4"), # Force SigV4, not the legacy v2
region_name="us-east-1", # MinIO ignores this, but boto3 requires it
)
# Generate a presigned GET URL — valid for 1 hour
url = s3_client.generate_presigned_url(
ClientMethod="get_object",
Params={
"Bucket": "my-bucket",
"Key": "uploads/report-2026-q1.pdf",
},
ExpiresIn=3600, # seconds
)
print(url)
The output looks like this (line-wrapped for readability):
https://s3.yourdomain.com/my-bucket/uploads/report-2026-q1.pdf
?X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Credential=your_access_key%2F20260525%2Fus-east-1%2Fs3%2Faws4_request
&X-Amz-Date=20260525T120000Z
&X-Amz-Expires=3600
&X-Amz-SignedHeaders=host
&X-Amz-Signature=abc123...
Anyone with this URL can download the file for the next hour. No credentials. No authentication headers. Just HTTP GET.
Presigned PUT URLs: The Right Way to Accept User Uploads
This is where most tutorials drop the ball. They show you GET presigned URLs and leave you to figure out uploads yourself. PUT presigned URLs are arguably more useful — they let clients upload files directly to MinIO, completely bypassing your application server.
The architecture: your backend generates a presigned PUT URL, returns it to the frontend, and the frontend pushes the file straight to storage. Your server never touches the file bytes.
# Generate a presigned PUT URL — valid for 15 minutes
upload_url = s3_client.generate_presigned_url(
ClientMethod="put_object",
Params={
"Bucket": "user-uploads",
"Key": f"uploads/{user_id}/{filename}",
"ContentType": "image/jpeg", # Enforce the content type in the signature
},
ExpiresIn=900,
)
On the frontend (plain JavaScript, no SDK needed):
async function uploadFile(presignedUrl, file) {
const response = await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: {
// Must match what you specified in ContentType when generating the URL
"Content-Type": "image/jpeg",
},
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
}
Gotcha #1: If you specify
ContentTypein the presigned URL parameters, the client must send that exactContent-Typeheader. If they don’t, MinIO will reject the request with a403 SignatureDoesNotMatch. This is by design — the content type is part of the signed payload.
Using the MinIO Python SDK Instead of boto3
MinIO ships its own Python SDK (minio) which is lighter and doesn’t carry all the AWS-specific baggage. For pure MinIO deployments, it’s the better choice:
from minio import Minio
from datetime import timedelta
client = Minio(
"s3.yourdomain.com",
access_key="your_access_key",
secret_key="your_super_secret_key",
secure=True, # Use HTTPS
)
# Presigned GET
get_url = client.presigned_get_object(
bucket_name="my-bucket",
object_name="uploads/report-2026-q1.pdf",
expires=timedelta(hours=1),
)
# Presigned PUT
put_url = client.presigned_put_object(
bucket_name="user-uploads",
object_name=f"uploads/{user_id}/{filename}",
expires=timedelta(minutes=15),
)
The MinIO SDK returns clean URLs with the same SigV4 signature — no behavioral difference from boto3, just less configuration noise.
Multipart Upload with Presigned URLs
For files over ~100 MB, you want multipart uploads. MinIO supports the full S3 multipart API, and you can presign each part. The flow:
- Your backend initiates the multipart upload and gets an
UploadId - Generate presigned URLs for each part (5 MB minimum per part, except the last)
- The client uploads each part directly
- Your backend completes the upload by assembling the parts
import boto3
from botocore.client import Config
s3 = boto3.client(
"s3",
endpoint_url="https://s3.yourdomain.com",
aws_access_key_id="your_access_key",
aws_secret_access_key="your_super_secret_key",
config=Config(signature_version="s3v4"),
region_name="us-east-1",
)
# Step 1: Initiate multipart upload
response = s3.create_multipart_upload(
Bucket="large-files",
Key="videos/recording.mp4",
)
upload_id = response["UploadId"]
# Step 2: Generate presigned URLs for each part
# Assume we're splitting a 50 MB file into 10 x 5 MB parts
part_urls = []
for part_number in range(1, 11):
url = s3.generate_presigned_url(
ClientMethod="upload_part",
Params={
"Bucket": "large-files",
"Key": "videos/recording.mp4",
"UploadId": upload_id,
"PartNumber": part_number,
},
ExpiresIn=3600,
)
part_urls.append(url)
# Return upload_id and part_urls to the frontend
# Frontend uploads each part and returns ETags
# Step 3: Complete the upload (after receiving ETags from client)
# parts = [{"PartNumber": 1, "ETag": "abc..."}, ...]
# s3.complete_multipart_upload(
# Bucket="large-files",
# Key="videos/recording.mp4",
# UploadId=upload_id,
# MultipartUpload={"Parts": parts},
# )
Gotcha #2: Multipart uploads that never complete leave orphaned parts consuming storage. Set a lifecycle rule on your bucket to abort incomplete multipart uploads after X days. In MinIO:
mc ilm rule add --expire-days 7 --expire-delete-marker myminio/large-files.
Gotchas, All of Them
Clock skew kills signatures. SigV4 is sensitive to time. MinIO will reject requests where the client clock differs from the server by more than 15 minutes. On a self-hosted setup, run chronyc or timedatectl and make sure NTP is working on the MinIO host. This is the #1 cause of mysterious 403 RequestTimeTooSkewed errors.
Reverse proxy path-style vs. virtual-hosted-style. AWS S3 uses virtual-hosted-style URLs (bucket.s3.amazonaws.com/key). MinIO defaults to path-style (s3.yourdomain.com/bucket/key). Most SDKs handle this, but if you’re behind nginx, you need to ensure the Host header is forwarded correctly. A misconfigured proxy that rewrites Host will break signature validation silently.
Don’t generate presigned URLs with root credentials in production. Create a dedicated IAM-style user in MinIO with the minimum permissions — read-only access to specific buckets, or write-only to an upload bucket. A leaked presigned URL already gives limited access by design, but leaked credentials give full access forever.
CORS is not optional for browser uploads. If the frontend uploads directly to MinIO, you need CORS configured on the bucket. MinIO’s CORS config via mc:
# Save this as cors.json
cat > /tmp/cors.json << 'EOF'
{
"CORSRules": [
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedOrigins": ["https://yourdomain.com"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
}
EOF
mc anonymous set-json /tmp/cors.json myminio/user-uploads
Never set AllowedOrigins: ["*"] on a bucket with write access unless you enjoy random people storing their junk on your server.
URL expiry is not revocation. Once a presigned URL is out in the wild, you can’t invalidate it before it expires — unless you rotate the signing key (which invalidates every presigned URL ever generated with that key). Design your expiry windows accordingly. For sensitive documents: 5–15 minutes. For CDN-style public assets: hours. Never days for anything sensitive.
Production-Ready Setup
A few patterns that make presigned URL systems hold up in production:
Generate URLs server-side, always. Never let clients generate their own presigned URLs. The signing happens on your backend with credentials that live there. The client receives only the signed URL.
Log URL generation, not just access. Your storage access logs show who downloaded what, but they don’t show which part of your application generated the URL. Add structured logging at generation time: user ID, object key, expiry, and a correlation ID. When a security incident happens, you’ll want this.
Use separate buckets for separate trust levels. Uploads land in a quarantine bucket. After validation (virus scan, size check, content type verification), move them to the serving bucket. This gives you a checkpoint before files become accessible.
Behind nginx, preserve the original Host:
location / {
proxy_pass https://cd-linux.club:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Required for large file uploads
client_max_body_size 5G;
proxy_request_buffering off; # Stream directly, don't buffer in nginx
}
proxy_request_buffering off is critical for large uploads. Without it, nginx buffers the entire upload to disk before forwarding it — which defeats the whole point of direct-to-storage uploads and will fill your disk.
Short-lived service accounts over root credentials. MinIO supports policy-based access control. For a service that only generates download URLs for a specific bucket:
# Create a policy that allows only GetObject on one bucket
cat > /tmp/readonly-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::my-bucket/*"]
}
]
}
EOF
mc admin policy create myminio readonly-my-bucket /tmp/readonly-policy.json
mc admin user add myminio download-service some_password
mc admin policy attach myminio readonly-my-bucket --user download-service
Now your download service generates presigned URLs using download-service credentials. If those credentials leak, the blast radius is read-only access to one bucket.
Quick Reference: Expiry Windows
| Use case | Recommended expiry |
|---|---|
| User downloads their own file | 15–60 min |
| One-time document share | 24 hours |
| Direct upload from browser | 10–15 min |
| Multipart upload parts | 1–2 hours |
| Email attachment link | 7 days max |
| Anything security-sensitive | 5–10 min |
These aren’t arbitrary — they reflect how long the URL might sit in a browser history, a Slack message, or a server log before it’s no longer useful to an attacker.
The pattern scales from a single-node MinIO on a VPS to a distributed cluster with erasure coding. The presigned URL mechanism is identical — same SigV4, same SDK calls, same production rules. The only thing that changes is the endpoint URL and how many disks MinIO spreads your data across.
Stop proxying file downloads through your app server. Generate a URL, hand it off, get out of the data path. That’s the whole architecture.