El problema
Tu aplicación deja subir un archivo. Lo natural es hacer lo mismo que harías con
un servidor: un endpoint POST /archivos, el cliente manda el archivo, tu
código lo recibe y lo guarda en S3.
En serverless eso choca contra tres muros, y el primero llega enseguida:
El tamaño. API Gateway admite 10 MB de payload. Una Lambda síncrona admite 6 MB. El techo real de tu endpoint es el menor de los dos, así que son 6 MB — y S3, el destino, admitía 48 TB. Has puesto dos porteros delante de un almacén que no necesitaba ninguno.
El coste. Aunque el archivo cupiera, estás pagando duración de Lambda por copiar bytes de una conexión a otra. Es el trabajo más caro por unidad de valor que puede hacer una función: nada de lógica, solo transporte.
El tiempo. Una subida lenta desde un móvil con mala cobertura mantiene viva tu invocación, consume concurrencia y corre contra el timeout de integración de 29 segundos de API Gateway.
La solución
Tu API deja de transportar y pasa a autorizar. Cuando el cliente quiere subir algo, pide permiso; tu Lambda comprueba quién es y qué puede hacer, firma una URL con caducidad, y responde. El cliente sube directamente a S3.
Una URL prefirmada es una URL normal de S3 con la firma de una credencial en la query string. Quien la tenga puede hacer esa operación concreta, sobre ese objeto concreto, hasta esa hora concreta, sin credenciales de AWS y sin que toques la política del bucket.
Los tres muros desaparecen a la vez: no hay techo de 6 MB porque nadie intermedia, no pagas duración porque tu función responde en milisegundos, y una subida de veinte minutos no consume nada tuyo.
Cuando la subida termina, S3 emite un evento y ahí sí invocas una función —para validar, transcodificar, indexar— pero ya con el archivo quieto en su sitio y con la clave, no con los bytes.
Cuándo usarlo
- Subidas de usuario: fotos, documentos, vídeos, adjuntos.
- Descargas privadas: una factura que solo puede ver su dueño, sin hacer el bucket público ni proxear el archivo.
- Intercambio con terceros que no tienen ni deben tener credenciales de AWS.
- Como pareja del claim-check: el productor deposita el payload grande y manda la clave.
Cuándo NO usarlo
Cómo implementarlo
- Autoriza en tu Lambda: quién es el usuario y si puede escribir ahí. Esa es toda la lógica que queda.
- Genera tú la clave del objeto. Nunca aceptes el nombre de archivo del cliente tal cual (ver trampas).
- Firma con caducidad corta. Minutos para subir, no días.
- Devuelve la URL y la clave. El cliente sube; tu backend ya sabe dónde va a aparecer.
- Reacciona al evento de S3, no a la respuesta del cliente: el cliente puede subir y desaparecer sin decírtelo.
- Pon CORS en el bucket si el que sube es un navegador.
- Ciclo de vida que limpie las subidas incompletas y los archivos que nadie confirmó.
El código
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({});
export const firmarSubida = async (evento: APIGatewayProxyEventV2) => {
const usuario = autenticar(evento);
const { tipo } = JSON.parse(evento.body!);
if (!['image/jpeg', 'image/png', 'application/pdf'].includes(tipo)) {
return { statusCode: 400, body: 'tipo no admitido' };
}
// La clave la generamos NOSOTROS. El nombre que manda el cliente
// solo sirve para enseñarselo de vuelta, nunca para construir la ruta.
const clave = `subidas/${usuario.id}/${crypto.randomUUID()}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: process.env.BUCKET,
Key: clave,
ContentType: tipo,
}),
// Corto. Y ojo: si la credencial que firma caduca antes,
// manda la credencial. Ver trampas.
{ expiresIn: 300 },
);
return { statusCode: 200, body: JSON.stringify({ url, clave }) };
};
// El trabajo de verdad ocurre cuando S3 avisa, no cuando el cliente
// dice que ha terminado: el cliente puede subir y desaparecer.
export const procesarArchivo = async (evento: S3Event) => {
for (const r of evento.Records) {
const clave = decodeURIComponent(r.s3.object.key.replace(/\+/g, ' '));
await validarYRegistrar(clave, r.s3.object.size);
}
};
Te va a morder
Coste
Aquí el patrón gana por goleada, y conviene ver por qué:
| Concepto | Con proxy | Con URL prefirmada |
|---|---|---|
| Duración de Lambda | la que tarde la subida | milisegundos |
| Payload de API Gateway | 10 MB de techo | no interviene |
| Techo real | 6 MB | 48 TB |
| Transferencia de entrada | pasa dos veces | una vez |
| Petición de S3 | 1 PUT | 1 PUT |
Lo que desaparece de la factura es la duración de Lambda proporcional al tamaño del archivo. Una función que tarda 8 segundos en recibir y reenviar 5 MB pasa a tardar 20 milisegundos en firmar una cadena. Con 100 000 subidas al mes, esa es la diferencia entre una línea visible y una invisible.
Subir a S3 no cuesta transferencia de entrada; lo que cuesta es sacar los archivos después. Si se descargan mucho, CloudFront delante.
Fuentes
Caducidad máxima según el tipo de credencial, comportamiento con credenciales
temporales, naturaleza de bearer token, reutilización, condición
s3:signatureAge, restricciones de red y errores comunes:
Download and upload objects with presigned URLs.
Tamaño máximo de objeto y de parte:
Amazon S3 endpoints and quotas.
Payload síncrono de Lambda y payload de API Gateway:
Lambda quotas
y Amazon API Gateway endpoints and quotas.