Skip to content

Presigned URLs

Cloud engines can mint presigned URLs so clients upload or download files directly against S3, GCS, or Azure — the payload never passes through your FastAPI server. This is the recommended pattern for large files.

sequenceDiagram
    participant C as Client
    participant A as FastAPI App
    participant S as Cloud Storage
    C->>A: POST /uploads/presign (filename, content type)
    A->>A: engine.presign_upload(key)
    A-->>C: PresignedURL {url, method, headers}
    C->>S: PUT file directly to signed URL
    S-->>C: 200 OK

Presigning an Upload

from fastapi import FastAPI
from filestore import PresignedURL, S3Engine

app = FastAPI()
s3 = S3Engine(bucket="my-bucket", region="us-east-1")


@app.post("/uploads/presign")
async def presign_upload(filename: str, content_type: str) -> PresignedURL:
    return await s3.presign_upload(
        f"uploads/{filename}",
        expires_in=600,               # seconds
        content_type=content_type,    # locked into the signature
    )

The returned PresignedURL is a Pydantic model:

{
  "url": "https://my-bucket.s3.us-east-1.amazonaws.com/uploads/a.png?X-Amz-...",
  "method": "PUT",
  "headers": {"Content-Type": "image/png"},
  "fields": {},
  "key": "uploads/a.png",
  "expires_in": 600,
  "storage": "s3"
}

Uploading from the Client

Send the file with the returned method, url, and headers:

const presigned = await (
  await fetch("/uploads/presign?filename=a.png&content_type=image/png", {method: "POST"})
).json();

await fetch(presigned.url, {
  method: presigned.method,
  headers: presigned.headers,
  body: file,
});

Always send the returned headers

The signature covers the headers. Azure upload URLs include x-ms-blob-type: BlockBlob, and any Content-Type passed at presign time must be sent verbatim — otherwise storage rejects the request with a signature mismatch.

Presigning a Download

@app.get("/downloads/presign")
async def presign_download(key: str) -> PresignedURL:
    return await s3.presign_download(key, expires_in=600)

The client performs a plain GET on the returned URL.

Backend Notes

  • Works with any boto3 credentials; SigV4 is used automatically.
  • Works against MinIO/LocalStack via endpoint_url.
  • Signing is local — no network call is made to presign.
  • Generates V4 signed URLs.
  • Requires credentials with a private key (e.g. a service account JSON). Plain Application Default Credentials from a metadata server cannot sign.
  • Generates SAS URLs.
  • Requires an account key — construct the engine with a connection string or shared-key credential. Token credentials (DefaultAzureCredential) raise ConfigurationError when presigning.
  • Upload URLs include the required x-ms-blob-type: BlockBlob header.
  • Presigning is not applicable; both methods raise NotSupportedError.

Deleting Objects

Engines that persist data also support deletion by key:

await s3.delete("uploads/a.png")            # S3
await gcs.delete("uploads/a.png")           # GCS
await azure.delete("uploads/a.png")         # Azure
await local.delete("a/b.txt")               # Local (resolved against base_dir)

FileData.key holds exactly the value to pass here — store it when the upload completes:

@app.post("/upload")
async def upload(store: Store = Depends(storage)):
    saved = store.first("file")
    db.save(key=saved.key)      # later: await engine.delete(key)
    return store

Note

LocalEngine.delete() refuses keys that escape base_dir (path traversal), and MemoryEngine raises NotSupportedError since nothing is persisted.

Tying It Together

A common production flow:

  1. Client asks your API to presign an upload (presign_upload) — you decide the key, enforce auth, and record a pending upload.
  2. Client PUTs the file directly to storage.
  3. Client confirms completion; you verify the object exists and mark it live.
  4. To serve private files, presign downloads (presign_download) with short expiries.
  5. To remove files, call engine.delete(key).