Subidas a Cloudflare R2 desde el navegador con AWS SDK
Un navegador puede subir archivos directamente a un bucket privado de Cloudflare R2 sin recibir tu clave secreta de R2. Ejecuta la pequeña aplicación local que aparece a continuación: autentica a la persona que solicita una subida, firma una sola clave de objeto y confirma los bytes almacenados antes de indicar que la operación se completó correctamente.
Mantén los permisos en el servidor
El AWS SDK se ejecuta en tu servidor Node.js. El navegador recibe una URL PUT prefirmada para un solo objeto, envía el archivo a R2 y pide al servidor que vuelva a leerlo. Comparar el tamaño almacenado y el resumen SHA-256 con los del archivo seleccionado permite detectar una transferencia HTTP exitosa que almacenó bytes incorrectos.
Esta es una demostración local para un solo usuario, con archivos desde cero bytes hasta 10 MiB. Su código de acceso identifica a un usuario local; el servidor elige sus claves y conserva como máximo ocho asignaciones en memoria. Al reiniciarlo se pierden esos registros, pero no se eliminan los objetos. Una aplicación desplegada necesita sus propias sesiones de usuario y registros persistentes de propiedad en lugar de ese código de acceso local.
Una URL prefirmada es una capacidad al portador, reutilizable hasta que caduque. Quien la obtenga puede usarla sin el código de acceso de la aplicación. Mantén las URL fuera de los registros, las herramientas de analítica y los mensajes compartidos; el ejemplo no las muestra ni reenvía a R2 el encabezado de autorización de la aplicación.
Prepara un bucket privado y un proyecto local
Usa un bucket privado de R2 que controles, en la jurisdicción predeterminada y con el acceso público deshabilitado. Necesitas su nombre, el ID de la cuenta de Cloudflare y un ID de clave de acceso S3 de R2 junto con su clave de acceso secreta, con el permiso Object Read & Write limitado a ese bucket. Sigue la guía de autenticación de R2 de Cloudflare para obtener estos valores. Iniciar sesión con Wrangler o vincular un bucket a un Worker no proporciona un par de claves S3.
Los comandos usan Bash en Linux, Node.js 26.12.0 (la versión Current probada) y Corepack 0.36.0 instalado por separado. Node.js 26 no incluye Corepack. Usa una versión de parche de Node.js 26 que siga recibiendo mantenimiento para las instalaciones nuevas; las versiones exactas de los paquetes que se indican a continuación permiten reproducir este ejemplo. Las versiones fijadas del SDK eran las más recientes que admitía el filtro de antigüedad de paquetes de dos días del proyecto cuando se probó. El navegador necesita la API File y Web Crypto, disponibles en este origen de bucle local en Chromium.
Desde un directorio con permisos de escritura, pega este bloque. Crea un proyecto nuevo y deja tu shell en su directorio original. Si el directorio ya existe, se detiene sin reemplazarlo. Si la instalación falla, el directorio nuevo se conserva para inspeccionarlo; elige otra ubicación vacía para volver a intentarlo desde cero.
(
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
)
Guarda los siguientes tres archivos dentro de r2-browser-demo. La configuración local de Yarn selecciona explícitamente
node_modules convencional y una caché del proyecto, incluso cuando un proyecto contenedor usa Plug’n’Play.
Configura CORS para el navegador
En la configuración de tu bucket, introduce esta política con el editor JSON del panel. Reemplaza una política existente solo si el bucket está dedicado a la demostración; en caso contrario, combina esta regla con las que tu aplicación ya necesita. Esto permite el origen exacto que muestra el servidor local, no cualquier nombre de localhost.
[
{
"AllowedOrigins": ["http://127.0.0.1:8080"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"]
}
]
La configuración CORS del bucket permite la solicitud PUT del navegador entre distintos orígenes. No autentica a la persona que solicita permiso a tu aplicación. La URL firmada sigue funcionando en un cliente HTTP que no aplique CORS, por lo que restringir los orígenes no puede sustituir la autorización del lado del servidor.
Guarda el firmante y el verificador de bytes
Guarda esto como storage.mts. El cliente usa el endpoint S3 estándar de R2 y credenciales explícitas,
por lo que no busca en tu configuración personal de AWS. WHEN_REQUIRED deshabilita aquí el cálculo opcional
de sumas de verificación de solicitudes del SDK; este ejemplo verifica por sí mismo todo el cuerpo
almacenado en lugar de tratar un ETag como un resumen SHA-256. AWS documenta el
ajuste de suma de verificación.
// 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',
})
Guarda esto como server.mts. Solo una solicitud autenticada desde la página local puede asignar una subida.
Ni la clave ni el propietario provienen de datos introducidos en el navegador. La verificación
resuelve el ID opaco mediante ese mismo registro de propiedad, de modo que quien hace la solicitud
no puede conseguir que el servidor lea un objeto arbitrario del bucket.
// 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()
})
}
La URL PUT dura 120 segundos y firma Content-Type: application/octet-stream. El navegador debe
enviar ese valor exacto. Esto almacena deliberadamente bytes de archivos arbitrarios con un tipo de
medio genérico; el encabezado es un metadato, no una prueba del formato ni de la seguridad del
archivo. Las comprobaciones de 10 MiB limitan el flujo normal de la aplicación y el verificador,
no el tamaño que un poseedor malintencionado podría enviar mediante PUT con una URL filtrada.
Guarda el cargador de archivos del navegador
Guarda esto como uploader.ts. Usa sintaxis JavaScript para que el servidor pueda entregarlo directamente como
módulo, sin un paso de compilación. Los controles permanecen deshabilitados hasta que termina la
lectura de verificación final, y se ignora un segundo envío mientras hay una operación pendiente.
// 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
}
})
Inicia la aplicación y sube un archivo
Desde el mismo directorio padre que usaste para la preparación, pega este bloque en una terminal. Los datos secretos que introduces se ocultan, y el subshell los mantiene fuera del entorno de tu shell principal. Elige un código de acceso local largo que puedas volver a introducir en la página; es independiente de tus credenciales de R2.
(
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
)
Abre http://127.0.0.1:8080, introduce el código en Local access code,
elige un File y selecciona Upload.
El estado final comienza con Verified e indica la cantidad de bytes y
la clave del objeto. Ese mensaje aparece solo después de que el servidor lee de R2 y ambos lados
comprueban el tamaño y el resumen esperados. Prueba con un archivo de texto, uno binario y uno vacío;
los nombres no se usan como claves de objeto.
Cada clic después de completar la operación asigna una nueva clave UUID. No reemplaza una subida anterior. El servidor muestra las claves asignadas para que puedas encontrar incluso una subida interrumpida. Después de las pruebas, espera al menos 120 segundos desde la última asignación, detén el servidor con Ctrl+C y elimina esos objetos exactos de tu bucket. Detener el servidor no revoca las URL ya emitidas ni elimina archivos.
Diagnostica una subida fallida
Si se deniega el permiso con un 401, comprueba el código de acceso local. Un 400 significa que el firmante no recibió un tamaño y un resumen válidos; un 429 significa que este proceso ha usado sus ocho asignaciones. Reiniciarlo permite una nueva sesión, por lo que este límite sirve para acotar la demostración local, no como cuota de producción.
Si se produce un error de red en el navegador, inspecciona la fase que falló en sus herramientas de
red. Un fallo en la solicitud de comprobación previa OPTIONS apunta al origen, el método y los
encabezados permitidos del bucket. Comprueba el puerto y cómo está escrito
127.0.0.1 antes de cambiar la política. El rechazo de una firma es un fallo de autorización de R2
independiente; cambiar el tipo de contenido firmado invalida el permiso. Las URL caducadas también
se rechazan, y sus respuestas pueden omitir los encabezados CORS, por lo que JavaScript en el
navegador podría ver únicamente un error de red. Cloudflare describe esa distinción en su
guía de CORS.
Un fallo de verificación significa que la aplicación no ha confirmado los bytes seleccionados. Inspecciona la clave asignada antes de reintentar: una respuesta fallida o perdida puede dejar un objeto almacenado. Un reintento crea una clave nueva y vuelve a enviar el archivo completo. Este ejemplo no afirma ofrecer reintentos ni reanudaciones automáticos.
Elige el siguiente paso para tu aplicación
Mantén los archivos más grandes fuera de la política de 10 MiB de esta demostración. La subida multiparte necesita un ciclo de vida autenticado independiente para iniciar subidas, firmar partes, completar subidas y abortarlas; aquí no está implementado. Un Worker puede alojar la lógica de la aplicación, pero la vía ejecutable de esta guía es el firmante local de Node.js. Las URL prefirmadas usan el nombre de host S3 de R2 y no pueden usar dominios personalizados.
Para una interfaz de subida más completa, explora Uppy. Conectar un cargador de archivos a tu aplicación sigue requiriendo el límite de propiedad y firma que se demuestra aquí.
