Skip to content

Browser Uploads to IPFS via the S3 API

Upload files from browser JavaScript directly to s3.ipfs.ninja using the AWS SDK for JavaScript v3, no backend proxy required. Users enter their own IPFS Ninja API key; the browser signs each request and reads the returned CID.

The one thing that trips up every first-time browser-S3 integration is CORS. This guide walks through the whole loop so browsers don't block you.

The security model

Before the technical bits: CORS is a browser-side convenience layer, not a security boundary. Every S3 API request still requires a valid SigV4 signature computed from your IPFS Ninja API key. A permissive CORS config doesn't let anyone use your bucket without that credential — CORS only tells the browser whether a specific origin is allowed to try.

What CORS actually prevents: an unintended origin (e.g. a stale copy of your app running on dev.myapp.com) sending signed requests from a browser context.

1. Create a bucket

Any user of the S3 API can CreateBucket. Bucket names are globally unique across all IPFS Ninja accounts, so pick something scoped to you (myapp-uploads-2026, not photos).

From your own code (any AWS SDK works):

javascript
import { S3Client, CreateBucketCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({
  endpoint: "https://s3.ipfs.ninja",
  region: "us-east-1",
  forcePathStyle: true,
  credentials: {
    accessKeyId: "bws_prefix",         // your API key prefix
    secretAccessKey: "bws_full_key",   // your full API key
  },
});

await s3.send(new CreateBucketCommand({ Bucket: "myapp-uploads-2026" }));

Or click New folder on ipfs.ninja/files — same underlying bucket.

2. Configure CORS on the bucket

Two equivalent ways. Pick one:

From the dashboard (fastest)

  1. Go to ipfs.ninja/files
  2. Find your bucket (folder), open the three-dot menu
  3. Click S3 CORS
  4. Add your origin(s) as chips (e.g. https://myapp.com, http://localhost:3000)
  5. Tick the methods you'll use (GET, PUT, POST, DELETE, HEAD)
  6. Leave AllowedHeaders as * and ExposeHeaders as ETag, x-amz-meta-cid, x-amz-request-id
  7. Save

From your code (PutBucketCors)

javascript
import { PutBucketCorsCommand } from "@aws-sdk/client-s3";

await s3.send(new PutBucketCorsCommand({
  Bucket: "myapp-uploads-2026",
  CORSConfiguration: {
    CORSRules: [{
      AllowedOrigins: ["https://myapp.com", "http://localhost:3000"],
      AllowedMethods: ["GET", "HEAD", "PUT", "POST", "DELETE"],
      AllowedHeaders: ["*"],
      // Critical: expose ETag and x-amz-meta-cid so your browser
      // code can read them off the successful PUT response.
      ExposeHeaders: ["ETag", "x-amz-meta-cid", "x-amz-request-id"],
      MaxAgeSeconds: 3600,
    }],
  },
}));

ExposeHeaders is not optional

Without ExposeHeaders: ["ETag", "x-amz-meta-cid"], the successful PUT response arrives (200 OK, upload succeeds server-side, file gets pinned), but the browser hides those headers from JavaScript. Your SDK will report failure even though the file is safely on IPFS. Always list the response headers you need to read.

3. Upload from the browser

The AWS SDK v3 works in browser bundles. Have your user enter their API key at runtime (never hardcode it), then:

javascript
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

// User pastes their key into an <input> in your UI
const apiKey = document.getElementById("api-key").value;
const bucket = "myapp-uploads-2026";

const s3 = new S3Client({
  endpoint: "https://s3.ipfs.ninja",
  region: "us-east-1",
  forcePathStyle: true,
  credentials: {
    accessKeyId: apiKey.slice(0, 12),  // first 12 chars are the prefix
    secretAccessKey: apiKey,           // full key is the secret
  },
});

async function uploadFile(file) {
  const put = new PutObjectCommand({
    Bucket: bucket,
    Key: file.name,
    Body: file,
    ContentType: file.type || "application/octet-stream",
  });

  const response = await s3.send(put);

  // Response headers include the IPFS CID
  const cid = response.Metadata?.cid || response.$metadata.httpHeaders?.["x-amz-meta-cid"];
  console.log("Pinned to IPFS:", cid);
  console.log("Public URL:", `https://ipfs.ninja/ipfs/${cid}`);
  return cid;
}

// Wire to a file input
document.getElementById("file-input").addEventListener("change", (e) => {
  uploadFile(e.target.files[0]).catch(console.error);
});

What the browser actually does

For a PUT /bucket/file.png request, the browser first sends an OPTIONS preflight:

OPTIONS /bucket/file.png HTTP/2
Host: s3.ipfs.ninja
Origin: https://myapp.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization,content-type,x-amz-content-sha256,x-amz-date

Our server checks your bucket's CORS config, matches Origin against AllowedOrigins, and responds:

HTTP/2 204
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, HEAD, PUT, POST, DELETE
Access-Control-Allow-Headers: *
Access-Control-Max-Age: 3600
Vary: Origin

If the origin doesn't match a rule, the response is 403 with no CORS headers — the browser blocks the real PUT and prints a helpful CORS error in the console.

Once preflight passes, the browser sends the real signed PUT. The response includes ACAO + Access-Control-Expose-Headers echoed back so your JS can read ETag and x-amz-meta-cid.

Troubleshooting

"Access to XMLHttpRequest ... has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present" → Your origin isn't in AllowedOrigins, or you haven't configured CORS on this bucket yet. Check with GetBucketCors.

"Reason: CORS preflight response did not succeed" → Preflight got a non-2xx from our side. Usually your bucket has no CORS config (check the dashboard), or your PutBucketCors call is missing AllowedOrigins.

PUT succeeds but response body is undefined / metadata is missingExposeHeaders doesn't include ETag or x-amz-meta-cid. Add them.

"Reason: Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'" → You're using credentials: 'include' on a fetch that's going through a wildcard-CORS response. AWS SDK v3 doesn't use credentials: 'include' by default — check for a custom HttpHandler or manual fetch wrapper in your code.

Preflight succeeds but the actual PUT fails with SigV4 error → Not a CORS issue anymore. Check that accessKeyId = first 12 chars of your API key and secretAccessKey = the full key, not just the suffix.

Uploads work from https://myapp.com but not https://www.myapp.com → Both are separate origins from the browser's perspective. Add both explicitly, or use a subdomain wildcard: https://*.myapp.com.

Cap and limits

  • 100 CORS rules per bucket, 64 KB total serialised
  • Preflight cached client-side for whatever MaxAgeSeconds you set (default 3600)
  • If you change your CORS config, existing browsers may serve from stale preflight cache until MaxAge expires — hard-refresh or wait to test

Cross-origin reads from the public gateway

Everything above covers writes — uploading to the S3 API from a browser. Public gateway reads (https://ipfs.ninja/ipfs/<CID>) are a separate, simpler CORS story: the apex gateway answers every request (and OPTIONS preflight) with Access-Control-Allow-Origin: *, so no bucket-level configuration is needed — it works for any origin out of the box.

Canvas taint

Loading a cross-origin image into a <canvas> normally taints it: toDataURL() and getImageData() throw a SecurityError unless the image was fetched with CORS. Because the apex gateway sends Access-Control-Allow-Origin: *, adding crossorigin="anonymous" to the <img> tag is enough:

html
<img
  src="https://ipfs.ninja/ipfs/bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
  crossorigin="anonymous"
  id="gw-img"
/>
<script>
  const img = document.getElementById("gw-img");
  img.onload = () => {
    const canvas = document.createElement("canvas");
    canvas.width = img.naturalWidth;
    canvas.height = img.naturalHeight;
    canvas.getContext("2d").drawImage(img, 0, 0);
    const dataUrl = canvas.toDataURL("image/png"); // no SecurityError
  };
</script>

Without crossorigin="anonymous", the browser still loads and displays the image fine — the canvas is only tainted the moment you draw a cross-origin image into it.

Reading Content-Range off a Range request

The gateway exposes Content-Range (along with Content-Length and the X-Ipfs-* headers) via Access-Control-Expose-Headers, so a cross-origin fetch() with a Range header can read it back:

javascript
const res = await fetch(
  "https://ipfs.ninja/ipfs/bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi",
  { headers: { Range: "bytes=0-1023" } }
);
console.log(res.status); // 206
console.log(res.headers.get("Content-Range")); // e.g. "bytes 0-1023/48213"

Without Content-Range listed in the response's Access-Control-Expose-Headers, res.headers.get("Content-Range") would return null in a cross-origin fetch even though the header is present on the wire.

See API Reference → Gateways → Cross-origin reads (CORS) for the full header list and the OPTIONS preflight response shape.

Reference