wandres.dev
R2 · almacenamiento de objetos

Acceso público y URLs presignadas

Un bucket nace privado. Para abrirlo al mundo hay dos vías: exponerlo tras un dominio propio —con caché, WAF y control de acceso— o, para desarrollo, un subdominio r2.dev. Y para dejar que un cliente suba o descargue directamente sin pasar los bytes por tu servidor, están las URLs presignadas: una llave firmada con fecha de caducidad. Vemos ambas y la idea que las une —separar la autorización de la transferencia—.

⏱ 15 min

Por defecto, un bucket de R2 es un cuarto cerrado: solo tu Worker y tus credenciales entran. Eso es correcto y prudente, pero tarde o temprano quieres que el mundo lea algo —un avatar, un PDF público, los assets de un sitio— o que un usuario suba un vídeo enorme sin que cada byte atraviese tu backend. Hay dos herramientas para abrir la puerta sin dejarla abierta de par en par. Los buckets públicos exponen su contenido tras un dominio. Las URLs presignadas reparten llaves temporales y acotadas para una sola operación. Ambas responden a la misma pregunta —cómo dejar que otros toquen tus bytes sin darles tus credenciales— con dos filos distintos.

🎯 Al terminar esta lección sabrás
  • Reconocer que un bucket es privado por defecto y qué significa hacerlo público.
  • Contrastar exponerlo tras un dominio propio con el subdominio r2.dev de desarrollo.
  • Generar una URL presignada de subida o descarga con la API de S3 y un tiempo de expiración.
  • Entender la URL presignada como una capacidad firmada: temporal, acotada y sin credenciales.
🌐

Dominio propio

Producción. El bucket se sirve tras un dominio de tu zona y hereda caché, WAF, Access y gestión de bots del edge.

🧪

Subdominio r2.dev

Solo desarrollo. URL gestionada por Cloudflare, limitada por tasa, sin caché configurable ni defensas. Cómoda para probar.

🔑

Presignada de subida

Una llave firmada que autoriza un PUT a una clave concreta durante un plazo. El cliente sube directo, sin credenciales.

Presignada de descarga

La misma idea para un GET: reparte enlaces temporales a objetos que el bucket mantiene privados, sin abrirlo.

Buckets públicos: el dominio como puerta

Un bucket privado solo se lee desde dentro de la confianza: tu Worker por binding, o un cliente con claves de acceso. Hacerlo público significa exponer sus objetos a internet por una URL, y R2 ofrece dos maneras que no conviene confundir.

La primera es un dominio propio. Conectas un dominio que gestionas en Cloudflare al bucket, y sus objetos quedan servibles bajo esa dirección. Esta es la vía de producción, y no solo por estética: al pasar por un dominio de tu zona, el bucket hereda toda la maquinaria del borde de Cloudflare —la caché para acelerar las lecturas repetidas, el WAF para filtrar tráfico, Cloudflare Access para exigir identidad, la gestión de bots—. El bucket deja de ser un almacén desnudo y se convierte en un origen con todas las defensas del edge delante.

La segunda es el subdominio r2.dev, una URL gestionada por Cloudflare que enciendes con un clic. Es cómoda para probar, pero está limitada por tasa y pensada solo para desarrollo: sin caché configurable, sin WAF, sin control de acceso. La regla es simple: r2.dev para trastear, dominio propio para servir de verdad. Y una cautela recurrente —si proteges el bucket con WAF o Access sobre tu dominio, acuérdate de desactivar el r2.dev, o dejarás una puerta trasera abierta que esquiva todas esas defensas—.

# Subir un objeto que luego servira un bucket publico tras tu dominio.
wrangler r2 object put mi-bucket/logo.png --file=./logo.png \
  --content-type=image/png --cache-control="public, max-age=31536000"

URLs presignadas: una llave con caducidad

El dominio público es para lo que quieres que cualquiera lea. Pero hay un caso distinto: dejar que una persona concreta haga una operación concreta durante un rato concreto —subir este vídeo, descargar aquel informe privado— sin darle credenciales ni abrir el bucket. Para eso está la URL presignada.

La idea es criptográficamente bonita. Tu servidor, que sí tiene las claves de acceso a R2, firma una URL que autoriza una operación específica sobre una clave específica, con una fecha de expiración incrustada en la firma. Entregas esa URL al cliente. El cliente la usa —un PUT para subir, un GET para descargar— hablando directamente con R2, que valida la firma y el plazo. Nunca viste las credenciales viajar; nunca abriste el bucket; la autorización cabe entera en una cadena firmada que caduca sola.

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

const s3 = new S3Client({
  region: "auto",
  endpoint: `https://${env.ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: { accessKeyId: env.R2_KEY_ID, secretAccessKey: env.R2_SECRET },
});

// Firmar una URL que autoriza SUBIR una clave concreta durante una hora.
const urlSubida = await getSignedUrl(
  s3,
  new PutObjectCommand({ Bucket: "mi-bucket", Key: "usuarios/42/avatar.png", ContentType: "image/png" }),
  { expiresIn: 3600 },
);
// Devuelves urlSubida al cliente; el cliente hace PUT directo a R2.

Cambiando PutObjectCommand por GetObjectCommand firmas una descarga en vez de una subida: la misma mecánica sirve para repartir enlaces de descarga temporales a objetos que el bucket mantiene privados.

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

// Firmar una URL que autoriza DESCARGAR un objeto privado durante cinco minutos.
const urlDescarga = await getSignedUrl(
  s3,
  new GetObjectCommand({ Bucket: "mi-bucket", Key: "facturas/2026-07.pdf" }),
  { expiresIn: 300 },
);
📝
Firma el mínimo privilegio y el mínimo tiempo

Una URL presignada es tan poderosa como lo que firmas en ella, así que acótala. Firma la clave exacta —no un prefijo— y la operación exacta —subir o descargar, no ambas—, y pon la expiración más corta que el flujo tolere: minutos para una descarga puntual, no horas. Cada holgura que concedes es superficie de ataque si la URL se filtra; la disciplina del mínimo privilegio y el mínimo tiempo es lo que mantiene segura una llave que, por diseño, va a viajar fuera de tu control.

💡
En el Worker, aws4fetch pesa menos

El aws-sdk completo es voluminoso para un isolate. Cuando la firma ocurre dentro del propio Worker, muchos equipos usan aws4fetch, una librería diminuta que firma peticiones al estilo AWS sin arrastrar el SDK entero. El concepto es idéntico —firmas una URL con caducidad—; solo cambias la herramienta por una más liviana para no inflar el bundle.

Sacar los bytes del camino del Worker

Reúne las dos ideas y verás para qué sirven de verdad. Sin URLs presignadas, si un usuario sube un vídeo de un gigabyte, ese vídeo tiene que atravesar tu Worker de camino a R2: tu código se vuelve una tubería por la que pasa cada byte, gastando tiempo y rozando límites. Con una URL presignada de subida, tu Worker solo hace una cosa —decidir si este usuario puede subir y firmar la llave— y el gigabyte viaja directo del navegador a R2, sin tocarte.

// El Worker autoriza y firma; no toca el fichero.
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const usuario = await autenticar(request, env);
    if (!usuario) return new Response("no autorizado", { status: 401 });
    const clave = `usuarios/${usuario.id}/${crypto.randomUUID()}`;
    const url = await firmarSubida(env, clave);   // devuelve una URL presignada
    return Response.json({ url, clave });
  },
} satisfies ExportedHandler<Env>;
flowchart LR
Cliente[cliente] --> Worker[worker firma la URL]
Worker --> Firma[URL presignada temporal]
Firma --> Cliente
Cliente --> R2[PUT directo a R2]
style Worker fill:#a6e3a1,color:#11111b
style R2 fill:#a6e3a1,color:#11111b
Separa la autorización de la transferencia y todo el diseño se reordena

Hay una confusión silenciosa en la que caen casi todos los backends: creer que quien decide si algo se permite tiene que ser también por donde ese algo pasa. Se mezclan dos responsabilidades que la teoría separa con nitidez —el plano de control, que autoriza, y el plano de datos, que transporta— y el resultado es un servidor que se convierte en cuello de botella de sus propios ficheros, porque cada subida y cada descarga lo atraviesan solo para que él pueda vigilarlas. La URL presignada disuelve esa fusión con una elegancia que vale la pena admirar: es una capacidad en el sentido más puro de la seguridad informática, un testigo al portador que lleva incrustado exactamente lo que autoriza —esta clave, esta operación, hasta esta hora— y nada más. Quien la tiene puede hacer eso y solo eso, y solo durante ese plazo; y como la firma es infalsificable sin la clave secreta, repartirla no filtra ningún poder de más. Tu Worker recupera su verdadero papel, que nunca fue mover bytes sino gobernar quién los mueve: comprueba la identidad, aplica la política, acuña la llave, y se aparta del camino para que el gigabyte viaje directo entre el cliente y R2. Esta es la misma separación que hace escalar a los sistemas serios —el token de acceso frente al recurso, el ticket frente al servicio, la firma frente al almacén— y una vez que la ves, empiezas a detectar en tus diseños todos los sitios donde el guardián se metió sin querer a hacer de mensajero. La pregunta que te transforma no es cómo hago pasar este fichero por mi servidor de forma más eficiente, sino por qué está pasando por mi servidor. Muchas veces la respuesta correcta es que no debería, y una llave firmada con fecha de caducidad es la herramienta que lo saca de en medio.

⚔️ Reparte llaves en lugar de mover bytes
  1. Conecta un bucket a un dominio propio y sirve un objeto público con una cabecera cache-control larga; comprueba que la segunda petición la sirve la caché del edge.
  2. Explica por qué el subdominio r2.dev no vale para producción y qué defensas ganas al pasar el bucket por tu dominio.
  3. Firma una URL presignada de subida con expiración de una hora y súbele un fichero desde un cliente sin credenciales de R2. Verifica que caduca.
  4. Reescribe un endpoint que hoy recibe un vídeo grande y lo reenvía a R2 para que en su lugar autentique, firme una URL presignada y deje que el cliente suba directo. Mide qué deja de pasar por el Worker.
  5. Argumenta, con el vocabulario de plano de control y plano de datos, por qué una URL presignada es una capacidad y por qué su caducidad es parte de su seguridad, no un adorno.