Handling uploads and binary payloads
Small files can go through the API. Large ones should not, and pre-signed URLs move the bytes without touching your application servers.
Small uploads through the API
POST /avatars HTTP/1.1
Content-Type: multipart/form-data; boundary=----X
------X
Content-Disposition: form-data; name="file"; filename="me.png"
Content-Type: image/png
...binary...
------X--from flask import Flask, request, jsonify
import imghdr
MAX_BYTES = 5 * 1024 * 1024
ALLOWED = {"png", "jpeg", "gif", "webp"}
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = MAX_BYTES
@app.post("/avatars")
def upload():
f = request.files.get("file")
if f is None:
return jsonify(error="file_required"), 400
# Read at most one byte more than the limit, then stop
head = f.stream.read(MAX_BYTES + 1)
if len(head) > MAX_BYTES:
return jsonify(error="file_too_large"), 413
# Trust the bytes, not the client's Content-Type or the file extension
kind = imghdr.what(None, head)
if kind not in ALLOWED:
return jsonify(error="unsupported_image"), 415
return jsonify(stored=save(head, kind)), 201- The declared
Content-Typeand the filename are attacker-controlled. Sniff the content and validate it. - Enforce the size limit while reading, not after buffering the whole body.
- Return 413 Payload Too Large when the limit is exceeded, so the client can react rather than guess.
- Reject path separators in a filename, or better, generate the storage key yourself and never use the client's name on disk.
Large uploads and downloads
# 1. Client asks your API for a place to put the file
# GET /uploads/presign?filename=video.mp4&size=734003200
def presign(filename: str, size: int, user_id: str) -> dict:
if size > MAX_VIDEO_BYTES:
raise PayloadTooLarge()
key = "uploads/" + user_id + "/" + uuid4().hex # key chosen by us
url = storage.generate_presigned_url(
"put_object",
Params={"Bucket": BUCKET, "Key": key},
ExpiresIn=900,
# Pin the conditions the signature must cover
)
return {"uploadUrl": url, "storageKey": key, "expiresIn": 900}
# 2. Client PUTs the bytes straight to object storage, not through your API
# 3. Client confirms: POST /uploads/complete {storageKey}
# The server then verifies the object size and type before recording it.# Range requests let a client resume a download in pieces
from flask import Response
def send_range(path: str, start: int, end: int, total: int) -> Response:
with open(path, "rb") as fh:
fh.seek(start)
chunk = fh.read(end - start + 1)
return Response(chunk, 206, headers={
"Content-Range": "bytes " + str(start) + "-" + str(end) + "/" + str(total),
"Accept-Ranges": "bytes",
"Content-Length": str(len(chunk)),
})⚠️
A pre-signed URL is a bearer credential for the duration of its lifetime. Keep the expiry short, scope the signature to one key and one method, cap the declared size, and never log the signed URL — anyone who reads the log can write to the bucket.
FAQ
Should the API ever stream a file itself?
Yes, when the file is small, when access must be authorised per request, or when you must add a watermark or a transformed version. Route anything above a few tens of megabytes, or anything hot, to object storage.
How do I scan uploads for malware?
Asynchronously, after the upload completes, and quarantine until the scan finishes. Scanning in the request path ties your API's latency and availability to the scanner.
Related
Securing REST APIs beyond authentication Long-running operations, webhooks and bulk endpoints
Last refreshed 2026-09-18.