Skip to content

Browser uploads

The usual pattern for user uploads in a web app: your server never handles the file. It signs a short-lived URL; the browser uploads straight to SwiftFS.

Output
browser ──(1) "I want to upload photo.jpg"──► your server
browser ◄─(2) presigned PUT URL (10 minutes)── your server   (signs with its access key)
browser ──(3) PUT the file──────────────────► SwiftFS        (checks the signature, and CORS)

This is the flow SwiftFS's JavaScript SDK test suite runs, end to end, in a real browser.

1. Allow your site's origin (CORS)

Browsers only send cross-origin uploads that the bucket's CORS rules allow:

JSON
{
  "CORSRules": [
    {
      "AllowedOrigins": ["https://app.example.com"],
      "AllowedMethods": ["PUT", "GET"],
      "AllowedHeaders": ["*"],
      "ExposeHeaders": ["ETag"],
      "MaxAgeSeconds": 3000
    }
  ]
}
Terminal
aws --endpoint-url "$S3_ENDPOINT" s3api put-bucket-cors --bucket acme-media --cors-configuration file://cors.json
aws --endpoint-url "$S3_ENDPOINT" s3api get-bucket-cors --bucket acme-media

SwiftFS answers the browser's preflight (OPTIONS) from these rules, and adds the CORS headers to actual responses, errors included. A bucket has up to 100 rules; an origin or header may contain one * wildcard.

2. Sign an upload on your server

JavaScript
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ endpoint: process.env.S3_ENDPOINT, region: process.env.AWS_REGION, forcePathStyle: true });

export async function uploadUrl(userId, fileName, contentType) {
  const key = `uploads/${userId}/${crypto.randomUUID()}-${fileName}`;
  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({ Bucket: "acme-media", Key: key, ContentType: contentType }),
    { expiresIn: 600 },
  );
  return { key, url };
}

Use a key that is scoped to this bucket (see access keys): the server only ever needs to write uploads.

3. Upload from the browser

JavaScript
const { url, key } = await (await fetch("/api/upload-url", { method: "POST", body: JSON.stringify({ name: file.name, type: file.type }) })).json();

const res = await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);

The Content-Type must be the one you signed. For files over a few hundred megabytes, sign the parts of a multipart upload instead.

Serving what was uploaded

  • Public media (avatars, product images): allow anonymous reads of a prefix with a bucket policy, then link to https://endpoint/acme-media/public/….
  • Private files (invoices, documents): your server signs a GET URL for each download. ResponseContentDisposition makes the browser save it under a friendly name:
JavaScript
import { GetObjectCommand } from "@aws-sdk/client-s3";

const url = await getSignedUrl(
  s3,
  new GetObjectCommand({ Bucket: "acme-media", Key: key, ResponseContentDisposition: 'attachment; filename="invoice.pdf"' }),
  { expiresIn: 300 },
);

Browser form uploads (POST Object) are not supported yet: use presigned PUTs.