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-Type and 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.

Securing REST APIs beyond authentication Long-running operations, webhooks and bulk endpoints

Last refreshed 2026-09-18.