Pasar un archivo por tu backend para subirlo a S3 es pagar dos veces el mismo peaje. El archivo viaja del navegador a tu API, consume memoria y ancho de banda de tu servidor, y luego viaja otra vez de tu API a S3. En una Lambda, además, te comes el límite de payload; en Fargate, ocupas un contenedor haciendo de tubería. La alternativa que uso desde hace años: el backend firma una URL temporal con permisos quirúrgicos, el navegador sube directo a S3, y tu API solo procesa metadatos. Elegí este patrón en los paneles que construyo para clientes porque escala sin tocar infraestructura; a cambio, el flujo tiene más pasos y hay que diseñar la seguridad con cuidado, porque una presigned URL mal acotada es una puerta abierta. Este artículo es el flujo completo.
El flujo en tres pasos
El patrón tiene tres actores: el navegador, tu API y S3. La API nunca ve el archivo.
// 1. El backend genera la URL firmada (Lambda o endpoint normal)
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({});
export async function crearUrlDeSubida(userId: string, contentType: string) {
if (!TIPOS_PERMITIDOS.has(contentType)) {
throw new Error('Tipo de archivo no permitido');
}
const key = `uploads/${userId}/${crypto.randomUUID()}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: process.env.UPLOADS_BUCKET,
Key: key,
ContentType: contentType,
ContentLength: undefined, // se limita con condiciones en POST policy si hace falta
}),
{ expiresIn: 300 } // 5 minutos, ni uno más
);
return { url, key };
}
El navegador hace un PUT directo a esa URL con el archivo como body. Cuando termina, notifica a tu API con la key, y ahí registras el archivo en PostgreSQL vía Prisma, disparas el procesamiento asíncrono, o lo que toque.
Las tres decisiones de seguridad que importan
La key la genera el servidor, siempre. Si dejas que el cliente elija el nombre del archivo, estás dejando que elija dónde escribe en tu bucket. La key incluye el userId autenticado y un UUID: el cliente no aporta ni un carácter.
Expiración corta y content-type fijado. Cinco minutos es suficiente para iniciar cualquier subida. Y firmar el ContentType en la URL obliga a que lo que se sube sea lo que se declaró — no evita que alguien renombre un ejecutable a .jpg, pero cierra la vía fácil.
El bucket de subidas no es el bucket final. Todo cae en un bucket de staging con una regla de ciclo de vida que borra objetos a las 24 horas. Un proceso posterior — en mi caso una Lambda disparada por el evento s3:ObjectCreated — valida el archivo de verdad (magic bytes, tamaño, escaneo si aplica) y lo mueve al bucket definitivo. Lo que nadie valida, se autodestruye solo.
💡 Una presigned URL no es "acceso a S3": es UNA operación, sobre UNA key, durante UN intervalo. Si tu URL firmada permite más que eso, no has entendido el patrón — has abierto un agujero con extra de pasos.
El evento de confirmación: no te fíes del cliente
El error clásico de este patrón es marcar el archivo como "subido" cuando el navegador dice que terminó. El navegador miente: se cierra la pestaña, falla la red, el usuario cancela. La fuente de verdad es S3, no el cliente.
Por eso el registro en base de datos tiene dos fases: la API crea la fila en estado pendiente al firmar la URL, y la Lambda del evento ObjectCreated la pasa a disponible cuando el objeto existe de verdad. Las filas que se quedan en pendiente más de una hora las limpia un job. El cliente puede notificar para acelerar la UX, pero su notificación es una optimización, nunca la verdad.
Cuándo no usar este patrón
Como siempre, hay trade-offs. Si los archivos son pequeños (JSON, avatares de pocos KB) y el volumen es bajo, pasar por el backend es más simple y la simplicidad gana. Si necesitas transformar el archivo en línea antes de guardarlo (redimensionar, transcodificar de forma síncrona), el backend tiene que verlo de todos modos. Y si tu frontend es una PWA offline-first, la subida directa complica la cola de sincronización: ahí a veces prefiero encolar el archivo localmente y subirlo desde un service worker cuando hay red, lo que cambia el diseño entero.
Para todo lo demás — documentos, imágenes, cualquier cosa que pese megabytes y llegue con concurrencia — dejar que S3 haga el trabajo pesado es de las pocas decisiones de arquitectura que no he lamentado nunca.