Usar URL firmadas para proteger el contenido de tu CDN
Tu aplicación puede autorizar una descarga sin hacer público el archivo. Con AWS CloudFront, tu servidor firma la URL de un objeto y le asigna una fecha de caducidad. Esta guía proporciona un programa de firma ejecutable en TypeScript y una comprobación de que la URL entrega los bytes previstos mientras las demás vías de acceso permanecen cerradas.
Comprender la seguridad de las CDN y las URL firmadas
Una URL firmada es una credencial al portador: cualquiera que la reciba puede usarla hasta que caduque. Autoriza al usuario en tu aplicación antes de emitir la URL y selecciona el objeto en el servidor en lugar de firmar URL arbitrarias proporcionadas por un cliente.
CloudFront verifica la firma con una clave pública de un grupo de claves de confianza asociado al comportamiento de caché correspondiente. La clave privada correspondiente permanece en tu servidor. El ejemplo usa una política predefinida, que vincula el acceso a una sola URL y a una hora límite; no vincula la descarga a la persona que la solicitó. Consulta cómo funcionan las URL firmadas de CloudFront.
Separa la autorización del usuario del acceso al origen
Dos controles deben estar coordinados. El grupo de claves de confianza controla las solicitudes de los usuarios a CloudFront. Para un origen S3 privado, un control de acceso al origen (OAC) y una política del bucket permiten que CloudFront lea el objeto mientras se siguen denegando las solicitudes anónimas directas a S3. Configurar solo uno de estos controles no establece ambas protecciones.
Esta página se centra en el programa de firma de la aplicación. Para conocer la infraestructura,
consulta nuestra guía de CDN de imágenes con S3 y CloudFront.
Usa las instrucciones de configuración de OAC de AWS
para la política del origen y su condición AWS:SourceArn específica de la distribución.
Usa un bucket S3 normal como origen; un endpoint de sitio web de S3 no puede usar OAC.
Requisitos previos para la implementación
Prepara lo siguiente antes de ejecutar el programa de firma:
- Una distribución de CloudFront desplegada con una URL HTTPS de un objeto y un objeto privado conocido en S3. Conserva una copia local de ese objeto para la comparación de bytes que aparece más adelante.
- Un par de claves RSA-2048 dedicado en formato PEM. Sube la clave pública a CloudFront, inclúyela en un grupo de claves de confianza y asocia ese grupo al comportamiento de caché que sirve el objeto. Sigue las instrucciones de AWS sobre pares de claves y grupos de claves de confianza.
- El ID de la clave pública y el archivo de clave privada correspondiente, que solo la cuenta del servidor pueda leer. El ID de la clave no es una clave de acceso de IAM ni un ID de grupo de claves. Firmar localmente no requiere una clave de acceso de AWS ni un perfil de credenciales del SDK.
- Un bucket privado con S3 Block Public Access habilitado, OAC configurado para firmar siempre las solicitudes al origen y una política del bucket que conceda acceso de lectura a esta distribución. Espera a que termine el despliegue antes de realizar las pruebas. Mantén sincronizado el reloj del servidor de firma.
- Node.js 26 y Corepack instalado por separado. Usa una versión de
parche de Node.js que siga recibiendo mantenimiento. Las versiones para reproducir el ejemplo
son Node.js 26.8.1, Yarn 4.12.0 y
@aws-sdk/cloudfront-signer3.1069.0; son versiones probadas, no requisitos mínimos intrínsecos.
Implementar URL firmadas con AWS CloudFront
Ejecuta esta configuración en Bash desde un directorio con permisos de escritura. Crea
cloudfront-url-demo y mantiene tu shell en su directorio original. Si el directorio ya
existe, la operación se rechaza; elige un nombre nuevo en lugar de sobrescribir un proyecto. El
archivo de bloqueo del subproyecto y la configuración local de Yarn aíslan la instalación de sus
paquetes del espacio de trabajo que lo contiene, incluso si este usa Plug’n’Play.
(
command -v node >/dev/null &&
command -v corepack >/dev/null &&
mkdir cloudfront-url-demo &&
cd cloudfront-url-demo &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0","dependencies":{"@aws-sdk/cloudfront-signer":"3.1069.0"}}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' 'ignorePath: true' 'enableGlobalCache: false' 'plugins: []' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn install
)
Si la instalación falla, conserva el proyecto y revisa el error del gestor de paquetes. Tras
corregir la causa, ejecuta corepack yarn install dentro de cloudfront-url-demo;
no repitas el paso de creación del directorio. Guarda este programa completo como
cloudfront-url-demo/signed-url.mts:
import { createPrivateKey } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { isAbsolute } from 'node:path'
import { getSignedUrl } from '@aws-sdk/cloudfront-signer'
async function main(): Promise<void> {
const args = process.argv.slice(2)
const [resourceUrl, lifetime = '300'] = args
const keyPairId = process.env.CLOUDFRONT_KEY_PAIR_ID
const keyFile = process.env.CLOUDFRONT_PRIVATE_KEY_FILE
if (args.length < 1 || args.length > 2 || !resourceUrl || !keyPairId || !keyFile) {
throw new Error('Missing configuration')
}
if (!/^[A-Za-z0-9]+$/u.test(keyPairId) || !isAbsolute(keyFile)) {
throw new Error('Invalid key configuration')
}
const url = new URL(resourceUrl)
if (
url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
url.href !== resourceUrl
) {
throw new Error('Use an exact HTTPS object URL without a query or fragment')
}
const seconds = Number(lifetime)
if (!/^\d+$/u.test(lifetime) || !Number.isSafeInteger(seconds) || seconds < 1 || seconds > 3600) {
throw new Error('Lifetime must be an integer from 1 to 3600 seconds')
}
const privateKey = await readFile(keyFile, 'utf8')
const key = createPrivateKey(privateKey)
if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails?.modulusLength !== 2048) {
throw new Error('Use an RSA-2048 private key')
}
const signedUrl = getSignedUrl({
url: resourceUrl,
keyPairId,
privateKey,
dateLessThan: new Date(Date.now() + seconds * 1000).toISOString(),
})
process.stdout.write(`${signedUrl}\n`)
}
main().catch(() => {
console.error('Could not sign URL. Check the HTTPS URL, key ID, RSA-2048 PEM file, and 1–3600 second lifetime.')
process.exitCode = 1
})
Sustituye los tres valores de configuración por los tuyos. El segundo argumento es un período de validez en segundos; si lo omites, se seleccionan 300 segundos. Este ejemplo acepta deliberadamente solo URL HTTPS exactas sin cadenas de consulta ni fragmentos y limita la validez a una hora.
CLOUDFRONT_KEY_PAIR_ID='YOUR_PUBLIC_KEY_ID' \
CLOUDFRONT_PRIVATE_KEY_FILE='/absolute/path/to/private-key.pem' \
node cloudfront-url-demo/signed-url.mts \
'https://YOUR_DISTRIBUTION.cloudfront.net/private/guide.pdf' 300
Si la ejecución tiene éxito, escribe una URL en la salida estándar. Una configuración no válida, una clave ilegible o mal formada, o un período de validez no válido provocan la salida del proceso con un código distinto de cero y sin ninguna URL. Una clave bien formada que no corresponda puede seguir firmando localmente; solo CloudFront puede determinar si el comportamiento desplegado confía en ella.
Pruebas y validación
Generar una cadena firmada no demuestra que la CDN aplique los controles. Guarda lo siguiente como
cloudfront-url-demo/verify-private.mts. Ejecuta el programa de firma, descarga la respuesta completa y la
compara con tu original local antes de probar las cuatro vías de acceso denegadas. Cada comprobación
HTTP tiene un límite de 15 segundos, incluido el cuerpo de la respuesta, y las redirecciones hacen
que la comprobación falle.
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { setTimeout as delay } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
async function main(): Promise<void> {
const [objectUrl, directS3Url, originalFile, ...extra] = process.argv.slice(2)
assert(objectUrl && directS3Url && originalFile && extra.length === 0, 'Supply both URLs and the original file')
const expected = await readFile(originalFile)
const signerFile = fileURLToPath(new URL('./signed-url.mts', import.meta.url))
function sign(seconds: number): string {
return execFileSync(process.execPath, [signerFile, objectUrl, String(seconds)], {
encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'],
}).trim()
}
async function check(url: string, allowed: boolean): Promise<void> {
const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(15_000) })
const bytes = Buffer.from(await response.arrayBuffer())
if (allowed) {
assert.equal(response.status, 200, 'Valid URL was denied')
assert.equal(Buffer.compare(bytes, expected), 0, 'Downloaded bytes differ from the original')
} else {
assert.equal(response.status, 403, 'Private access was not denied')
assert.notEqual(Buffer.compare(bytes, expected), 0, 'Denied response disclosed the object')
}
}
const valid = sign(300)
await check(valid, true)
await check(objectUrl, false)
const tampered = new URL(valid)
const signature = tampered.searchParams.get('Signature')
assert(signature, 'Missing signature')
tampered.searchParams.set('Signature', `${signature[0] === 'A' ? 'B' : 'A'}${signature.slice(1)}`)
await check(tampered.href, false)
const expired = sign(1)
await delay(Math.max(0, Number(new URL(expired).searchParams.get('Expires')) * 1000 - Date.now()) + 1500)
await check(expired, false)
await check(directS3Url, false)
console.log('Private delivery checks passed.')
}
main().catch(() => {
console.error('Private delivery check failed. Check the object bytes and the CloudFront and S3 access configuration.')
process.exitCode = 1
})
Ejecútalo con la URL del objeto en CloudFront, la URL directa de S3 del mismo objeto y su original local:
CLOUDFRONT_KEY_PAIR_ID='YOUR_PUBLIC_KEY_ID' \
CLOUDFRONT_PRIVATE_KEY_FILE='/absolute/path/to/private-key.pem' \
node cloudfront-url-demo/verify-private.mts \
'https://YOUR_DISTRIBUTION.cloudfront.net/private/guide.pdf' \
'https://YOUR_BUCKET.s3.us-east-1.amazonaws.com/private/guide.pdf' \
'/absolute/path/to/original-guide.pdf'
El script imprime Private delivery checks passed. solo después de recibir los bytes previstos y de que las cuatro solicitudes denegadas devuelvan 403 sin revelar esos bytes. La comprobación de caducidad inicia una nueva solicitud después del vencimiento. CloudFront comprueba la caducidad cuando comienza una solicitud; no interrumpe una descarga que ya está en curso cuando se supera ese momento.
Solucionar problemas comunes
Si la solicitud válida falla, confirma primero el comportamiento de caché desplegado, el ID de la clave pública del grupo de confianza y la clave privada correspondiente. Un 403 por sí solo no permite distinguir una firma incorrecta de un problema de acceso al origen. Comprueba también la ruta del objeto, OAC y la política del bucket. La guía de AWS para solucionar errores 403 detalla esas comprobaciones.
Si la solicitud válida devuelve bytes diferentes, revisa la URL del objeto y el original local; después, comprueba si hay una versión anterior del objeto en caché. Si una solicitud sin firma tiene éxito, revisa el comportamiento de caché que realmente corresponde a esa ruta. Si el acceso directo a S3 tiene éxito, corrige la política de acceso del bucket antes de emitir enlaces de descarga. El verificador prueba únicamente este objeto y esta ruta de la distribución; repite la prueba para otros comportamientos protegidos.
Buenas prácticas de seguridad
Mantén la clave privada fuera de los paquetes del navegador, del control de versiones y de los registros. Móntala desde el almacenamiento de secretos de tu servidor como un archivo con permisos restringidos. Trata también la URL firmada como un secreto: evita registrarla en herramientas de analítica, registros de solicitudes o mensajes de soporte. Compártela solo con el destinatario autorizado mediante HTTPS.
Elige un período de validez corto que deje tiempo suficiente para iniciar la descarga. Para rotar las claves, despliega la nueva clave pública junto con la antigua antes de cambiar el programa de firma; conserva la clave antigua hasta que hayan caducado las URL aún vigentes, salvo que quieras revocarlas. AWS documenta este solapamiento en su procedimiento de rotación de claves.
Una vez que la entrega privada supere las comprobaciones, conecta el programa de firma con la decisión de autorización de tu aplicación. Devuelve una URL solo para el objeto al que el usuario autenticado tenga derecho a acceder y mantén las mismas comprobaciones de entrega cuando cambies la distribución o la política de su origen.
