Browser uploads to Cloudflare R2 with AWS SDK
A browser can upload directly to a private Cloudflare R2 bucket without receiving your R2 secret key. Run the small local app below: it authenticates the person requesting an upload, signs one object key, and confirms the stored bytes before displaying success.
Keep permission on the server
The AWS SDK runs on your Node.js server. The browser receives a presigned PUT URL for one object, sends the file to R2, and asks the server to read it back. Comparing the stored size and SHA-256 digest with the selected file catches a successful HTTP transfer that stored the wrong bytes.
This is a local, single-user demonstration with files from zero bytes through 10 MiB. Its access code identifies one local user; the server chooses their keys and retains at most eight allocations in memory. Restarting loses those records but does not delete objects. A deployed app needs its own user sessions and persistent ownership records in place of that local access code.
A presigned URL is a bearer capability, reusable until it expires. Someone who obtains it can use it without the app’s access code. Keep URLs out of logs, analytics, and shared messages; the example neither displays them nor forwards the app’s authorization header to R2.
Prepare a private bucket and local project
Use a private, default-jurisdiction R2 bucket you control, with public access disabled. You need its name, Cloudflare account ID, and an R2 S3 access key ID and secret access key with Object Read & Write permission scoped to that bucket. Follow Cloudflare’s R2 authentication guide to obtain these values. A Wrangler login or Worker bucket binding does not supply an S3 key pair.
The commands use Bash on Linux, Node.js 26.12.0 (the tested Current release), and separately installed Corepack 0.36.0. Node.js 26 does not bundle Corepack. Use a maintained Node.js 26 patch for new installations; the exact package versions below make this example reproducible. The SDK pins were the newest releases admitted by the project’s two-day package age gate when tested. The browser needs the File API and Web Crypto, available on this loopback origin in Chromium.
From a writable directory, paste this block. It creates a new project and leaves your shell in its original directory. If the directory already exists, it stops without replacing it. An installation failure leaves the new directory for inspection; choose another empty location for a fresh attempt.
(
set -eu
command -v node >/dev/null || { printf 'Install Node.js first.\n' >&2; exit 1; }
command -v corepack >/dev/null || { printf 'Install Corepack first.\n' >&2; exit 1; }
node -e 'if (Number(process.versions.node.split(".")[0]) !== 26) process.exit(1)'
mkdir r2-browser-demo
cd r2-browser-demo
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.18.1"}' > package.json
printf '%s\n' 'nodeLinker: node-modules' 'enableGlobalCache: false' 'ignorePath: true' 'npmMinimalAgeGate: 2d' > .yarnrc.yml
touch yarn.lock
corepack yarn add --exact @aws-sdk/client-s3@3.1150.0 @aws-sdk/s3-request-presigner@3.1150.0 zod@4.6.7
)
Save the next three files inside r2-browser-demo. The local Yarn configuration explicitly selects
ordinary node_modules and a project cache, including when an enclosing project uses Plug’n’Play.
Configure browser CORS
In your bucket’s settings, enter this policy using the dashboard’s JSON editor. Replace an existing policy only for a dedicated demo bucket; otherwise merge this rule with rules your application already needs. This allows the exact origin printed by the local server, not every localhost name.
[
{
"AllowedOrigins": ["http://127.0.0.1:8080"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"]
}
]
Bucket CORS permits the browser’s cross-origin PUT. It does not authenticate the person asking your app for permission. The signed URL still works in an HTTP client without CORS enforcement, so restricting origins cannot replace server-side authorization.
Save the signer and byte verifier
Save this as storage.mts. The client uses the standard R2 S3 endpoint and explicit credentials,
so it does not search your personal AWS configuration. WHEN_REQUIRED disables optional SDK request
checksum calculation here; this example checks the complete stored body itself rather than treating
an ETag as a SHA-256 digest. AWS documents the checksum
setting.
// storage.mts
import { randomUUID } from 'node:crypto'
import { S3Client } from '@aws-sdk/client-s3'
import { z } from 'zod'
const parsed = z.object({
CLOUDFLARE_ACCOUNT_ID: z.string().regex(/^[a-f0-9]{32}$/),
R2_BUCKET_NAME: z.string().min(3).max(63),
R2_ACCESS_KEY_ID: z.string().min(1),
R2_SECRET_ACCESS_KEY: z.string().min(1),
UPLOAD_TOKEN: z.string().min(32).max(256),
PORT: z.coerce.number().int().min(1024).max(65535).default(8080),
}).safeParse(process.env)
if (!parsed.success) throw new Error('Set all five configuration values; use a 32+ character access code.')
export const config = parsed.data
export const origin = `http://127.0.0.1:${config.PORT}`
export const prefix = `browser-demo/${randomUUID()}/`
export const maxBytes = 10 * 1024 * 1024
export const r2 = new S3Client({
region: 'auto',
endpoint: `https://${config.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.R2_ACCESS_KEY_ID,
secretAccessKey: config.R2_SECRET_ACCESS_KEY,
},
requestChecksumCalculation: 'WHEN_REQUIRED',
})
Save this as server.mts. Only an authenticated request from the local page can allocate an upload.
Neither the key nor the owner comes from browser input. Verification resolves the opaque ID through
that same ownership record, so a caller cannot make the server read an arbitrary bucket object.
// server.mts
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { z } from 'zod'
import { config, maxBytes, origin, prefix, r2 } from './storage.mts'
const inputSchema = z.object({
size: z.number().int().min(0).max(maxBytes),
sha256: z.string().regex(/^[a-f0-9]{64}$/),
}).strict()
const allocations = new Map<string, { owner: string; key: string; size: number; sha256: string }>()
const page = `<!doctype html><html lang="en"><meta charset="utf-8">
<title>R2 browser upload</title><main><h1>R2 browser upload</h1>
<form><fieldset>
<label>Local access code <input id="accessCode" type="password" autocomplete="off" required></label>
<label>File <input id="file" type="file" required></label>
<button>Upload</button>
</fieldset></form><p role="status" aria-live="polite">Choose a file, including an empty file.</p>
<script type="module" src="/uploader.js"></script></main></html>`
function reply(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
async function json(req: IncomingMessage): Promise<unknown> {
const parts: Buffer[] = []
let length = 0
for await (const part of req) {
length += part.length
if (length > 4096) throw new Error('Request too large')
parts.push(part)
}
return JSON.parse(Buffer.concat(parts).toString('utf8'))
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Referrer-Policy', 'no-referrer')
if (req.headers.host !== new URL(origin).host) return reply(res, 403, { error: 'Use the printed local address.' })
if (req.method === 'GET' && req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(page)
return
}
if (req.method === 'GET' && req.url === '/uploader.js') {
res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8' })
res.end(await readFile(new URL('./uploader.ts', import.meta.url)))
return
}
if (!req.url?.startsWith('/api/')) return reply(res, 404, { error: 'Not found.' })
const supplied = Buffer.from(req.headers.authorization ?? '')
const expected = Buffer.from(`Bearer ${config.UPLOAD_TOKEN}`)
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) {
return reply(res, 401, { error: 'Access code required.' })
}
if (req.headers.origin !== origin) return reply(res, 403, { error: 'Origin refused.' })
const owner = 'local-user'
if (req.method === 'POST' && req.url === '/api/uploads') {
const input = inputSchema.safeParse(await json(req))
if (!input.success) return reply(res, 400, { error: 'Expected size 0–10 MiB and a SHA-256 digest.' })
if (allocations.size >= 8) return reply(res, 429, { error: 'Eight allocations used. Restart for a new session.' })
const id = randomUUID()
const key = `${prefix}${owner}/${id}`
allocations.set(id, { owner, key, ...input.data })
// Log only the opaque key, so failed uploads can be found and removed.
console.info(`Allocated key: ${key}`)
const url = await getSignedUrl(r2, new PutObjectCommand({
Bucket: config.R2_BUCKET_NAME, Key: key, ContentType: 'application/octet-stream',
}), { expiresIn: 120, signableHeaders: new Set(['content-type']) })
return reply(res, 200, { id, key, url })
}
const match = /^\/api\/uploads\/([a-f0-9-]{36})\/verify$/.exec(req.url)
const allocation = match ? allocations.get(match[1]) : undefined
if (req.method !== 'POST' || !allocation || allocation.owner !== owner) {
return reply(res, 404, { error: 'Upload not found in this session.' })
}
const object = await r2.send(new GetObjectCommand({
Bucket: config.R2_BUCKET_NAME, Key: allocation.key,
}))
if (!object.Body) throw new Error('Missing stored body')
const reader = object.Body.transformToWebStream().getReader()
const hash = createHash('sha256')
let size = 0
try {
for (;;) {
const chunk = await reader.read()
if (chunk.done) break
size += chunk.value.length
if (size > maxBytes) return reply(res, 409, { error: 'Stored object exceeds 10 MiB.' })
hash.update(chunk.value)
}
} finally {
await reader.cancel()
reader.releaseLock()
}
const sha256 = hash.digest('hex')
if (size !== allocation.size || sha256 !== allocation.sha256) {
return reply(res, 409, { error: 'Stored bytes differ from the selected file.' })
}
reply(res, 200, { key: allocation.key, bytes: size, sha256 })
}
const server = createServer((req, res) => {
handle(req, res).catch(() => {
console.error('Upload request failed. Check local configuration and R2 access.')
if (!res.headersSent) reply(res, 500, { error: 'Upload request failed.' })
else res.end()
})
})
server.on('error', () => {
console.error('Cannot start the upload server. Check whether the port is already in use.')
r2.destroy()
process.exitCode = 1
})
server.listen(config.PORT, '127.0.0.1', () => console.info(`Open ${origin}`))
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, () => {
server.close()
server.closeAllConnections()
r2.destroy()
})
}
The PUT URL lasts 120 seconds and signs Content-Type: application/octet-stream. The browser must
send that exact value. This deliberately stores arbitrary file bytes with a generic media type;
the header is metadata, not evidence of the file’s format or safety. The 10 MiB checks constrain the
app’s normal path and verifier, not the size a hostile holder could PUT using a leaked URL.
Save the browser uploader
Save this as uploader.ts. It uses JavaScript syntax so the server can deliver it directly as a
module, without a build step. Controls stay disabled through final readback, and a second submit
while work is pending is ignored.
// uploader.ts: JavaScript syntax, served directly to the browser.
const form = document.querySelector('form')
const fields = document.querySelector('fieldset')
const accessCode = document.querySelector('#accessCode')
const input = document.querySelector('#file')
const status = document.querySelector('[role="status"]')
if (!(form instanceof HTMLFormElement) || !(fields instanceof HTMLFieldSetElement) ||
!(accessCode instanceof HTMLInputElement) || !(input instanceof HTMLInputElement) ||
!(status instanceof HTMLElement)) throw new Error('Upload controls are missing')
let busy = false
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (busy) return
const file = input.files?.[0]
if (!file) return
if (file.size > 10 * 1024 * 1024) {
status.textContent = 'Choose a file of at most 10 MiB.'
return
}
busy = true
fields.disabled = true
let key = ''
const authorization = `Bearer ${accessCode.value}`
try {
status.textContent = 'Preparing upload…'
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
const sha256 = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
const permission = await fetch('/api/uploads', {
method: 'POST',
headers: { Authorization: authorization, 'Content-Type': 'application/json' },
body: JSON.stringify({ size: file.size, sha256 }),
})
if (!permission.ok) throw new Error(`Permission refused (${permission.status}).`)
const allocation = await permission.json()
key = allocation.key
status.textContent = 'Uploading…'
const uploaded = await fetch(allocation.url, {
method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' },
credentials: 'omit', referrerPolicy: 'no-referrer',
})
if (!uploaded.ok) throw new Error(`R2 refused the upload (${uploaded.status}).`)
status.textContent = 'Verifying stored bytes…'
const verification = await fetch(`/api/uploads/${allocation.id}/verify`, {
method: 'POST', headers: { Authorization: authorization },
})
if (!verification.ok) throw new Error('Stored bytes could not be verified.')
const receipt = await verification.json()
if (receipt.key !== key || receipt.bytes !== file.size || receipt.sha256 !== sha256) {
throw new Error('Stored bytes could not be verified.')
}
status.textContent = `Verified ${receipt.bytes.toLocaleString()} bytes. Key: ${key}`
} catch (error) {
const message = error instanceof Error && /^(Permission refused|R2 refused|Stored bytes)/.test(error.message)
? error.message : 'Upload did not complete. Check the connection and bucket CORS.'
status.textContent = `${message}${key ? ` Key: ${key}` : ''}`
} finally {
busy = false
fields.disabled = false
}
})
Start the app and upload a file
From the same parent directory used for setup, paste this block in a terminal. Secret inputs are hidden, and the subshell keeps them out of your parent shell’s environment. Choose a long local access code you can enter again in the page; it is separate from your R2 credentials.
(
set -eu
cd r2-browser-demo
read -r -p 'Cloudflare account ID: ' CLOUDFLARE_ACCOUNT_ID
read -r -p 'R2 bucket name: ' R2_BUCKET_NAME
read -r -s -p 'R2 access key ID: ' R2_ACCESS_KEY_ID; printf '\n'
read -r -s -p 'R2 secret access key: ' R2_SECRET_ACCESS_KEY; printf '\n'
read -r -s -p 'Local access code (32+ characters): ' UPLOAD_TOKEN; printf '\n'
export CLOUDFLARE_ACCOUNT_ID R2_BUCKET_NAME R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY UPLOAD_TOKEN
node server.mts
)
Open http://127.0.0.1:8080, enter the code in Local access code,
choose a File, and select Upload.
The final status begins with Verified and gives the byte count and
object key. That message appears only after the server reads R2 and both sides check the expected
size and digest. Try a text file, a binary file, and an empty file; names are not used as object keys.
Each click after completion allocates a new UUID key. It does not replace a previous upload. The server prints allocated keys so you can find even an interrupted upload. After testing, wait at least 120 seconds after the last allocation, stop the server with Ctrl+C, and remove those exact objects from your bucket. Stopping the server does not revoke URLs already issued or remove files.
Diagnose a failed upload
If permission is refused with 401, check the local access code. A 400 means the signer did not receive a valid size and digest; 429 means this process has used its eight allocations. Restarting allows a new session, so this limit is for a bounded local demonstration, not a production quota.
For a browser network error, inspect the failed phase in your browser’s network tools. A failed
OPTIONS preflight points to the bucket’s allowed origin, method, and headers. Check the port and
127.0.0.1 spelling before changing the policy. A signature refusal is a separate R2 authorization
failure; changing the signed content type invalidates permission. Expired URLs are also refused,
and their responses can omit CORS headers, so browser JavaScript may see only a network error.
Cloudflare describes that distinction in its CORS guide.
A verification failure means the app has not confirmed the selected bytes. Inspect the allocated key before retrying: a failed or lost response can leave a stored object. A retry creates a new key and sends the whole file again. This example makes no automatic retry or resume claim.
Choose the next step for your app
Keep larger files outside this demo’s 10 MiB policy. Multipart upload needs a separate authenticated lifecycle for starting, signing parts, completing, and aborting uploads; it is not implemented here. A Worker can host application logic, but this guide’s executable path is the local Node.js signer. Presigned URLs use R2’s S3 hostname and cannot use custom domains.
For a richer upload interface, explore Uppy. Connecting an uploader to your app still requires the ownership and signing boundary demonstrated here.
