Read, write, and stream objects

Put, get, and authorize objects

Write objects with metadata, handle missing keys, and authorize every operation before using the requested key.

The bucket binding gives you four verbs that cover most applications: put, get, head, and delete.

Use put to store the request body or another stream, and set the content metadata while you’re at it:

await env.FILES.put(`tenant/${tenantId}/uploads/${id}.pdf`, request.body, {
  httpMetadata: { contentType: 'application/pdf' },
})

The second argument can be a stream, an ArrayBuffer, a string, or a Blob. Passing request.body directly streams the upload into R2 without holding it in Worker memory.

Use get for the body and head when you only need metadata. head is the cheaper call when you want to know whether an object exists or how big it is, without transferring the bytes.

get returns null for a missing key. Handle it and return 404:

const object = await env.FILES.get(key)

if (!object) {
  return new Response('Not found', { status: 404 })
}

const headers = new Headers()
object.writeHttpMetadata(headers)
return new Response(object.body, { headers })

writeHttpMetadata copies the stored content type onto the response, so the browser knows what it received.

Authorization is your job

A private bucket does not give you tenant authorization. Private only means anonymous internet traffic can’t reach the bucket. Every request that arrives at your Worker can reach it, through your code.

So verify that the authenticated user owns the logical key before reading, replacing, listing, or deleting it:

const key = `tenant/${session.tenantId}/uploads/${filename}`

if (filename.includes('/') || filename.includes('..')) {
  return new Response('Invalid name', { status: 400 })
}

Build the prefix from the session, never from the request. The client only supplies the final filename segment, and you reject anything shaped like a path.

The same rule applies to delete. A delete endpoint that takes a full key from the request lets one user wipe another user’s files with a single call.

Test this the way an attacker would. Request ../../tenant/456/uploads/secret.pdf as a filename and confirm you get a 400, not another tenant’s file. One curl with a hostile key tells you more than a week of happy-path testing.

Try this: upload one PDF with put, check it with head, serve it with get, then run the hostile filename test against every endpoint that touches the bucket.

Lesson completed