CDN Privada Segura
Entrega global de archivos privados con CloudFront + S3 + Signed URLs y Origin Access Control.
Problema & Solución
Problema
Una plataforma SaaS necesita entregar archivos sensibles (PDFs, videos, contratos) a usuarios autenticados de forma global, con baja latencia, sin exponer el bucket S3 directamente a Internet ni permitir el acceso a URLs permanentes que puedan filtrarse.
Solución
Se implementa CloudFront como CDN con Origin Access Control (OAC) para autenticar solicitudes hacia S3 mediante SigV4. El bucket es completamente privado (BlockPublicAccess). El backend genera Signed URLs con expiración corta (15 min) firmadas con un CloudFront Key Pair almacenado en AWS Secrets Manager. CloudFront valida la firma en el edge antes de hacer el forward al origen.
Diagrama de Arquitectura

Cómo Funciona
Autenticación del usuario y solicitud de recurso
El usuario autenticado solicita un recurso privado al backend. El backend verifica la sesión (JWT/Cookie) y determina que tiene permisos sobre el archivo solicitado.
Generación del Signed URL en el backend
El backend obtiene el CloudFront Key Pair desde AWS Secrets Manager. Con el Private Key y el Key Pair ID, firma la URL usando HMAC-SHA1. La URL incluye el dominio CloudFront, el path del objeto, la fecha de expiración (900 segundos), y la firma. Este proceso ocurre en memoria — la clave privada nunca se escribe a disco.
CloudFront valida la Signed URL en el edge
Cuando el usuario hace el request con la Signed URL, CloudFront intercepta en el edge location más cercano. Verifica: (a) la firma usando el Public Key del Key Pair configurado, (b) que X-Amz-Expires no haya vencido, (c) que el dominio y path coincidan. Si cualquier verificación falla → 403 Forbidden.
Cache HIT: respuesta desde el edge
Si el objeto ya está en el cache del edge (dentro del TTL configurado), CloudFront retorna directamente el contenido sin contactar S3. Latencia: ~5-15ms global. El cache key no incluye los parámetros de firma para evitar fragmentación del cache.
Cache MISS: fetch desde S3 con OAC
Si el objeto no está en cache, CloudFront hace un request a S3 firmado con SigV4 usando Origin Access Control. S3 valida que el request proviene del distribution específico (via AWS:SourceArn en la bucket policy). S3 retorna el objeto → CloudFront lo cachea según Cache-Control headers → sirve al usuario.
Expiración de URL y renovación
Después de 15 minutos, la Signed URL expira. Si el usuario intenta reusarla, CloudFront retorna 403. El cliente debe solicitar una nueva URL al backend. Esto garantiza que URLs filtradas o compartidas tengan una ventana de exposición mínima.
Servicios AWS
Amazon CloudFront
CDN global + validación de Signed URLs
Distribution configurado con HTTPS-only, TLSv1.2_2021, HSTS headers. Cache behaviors por path pattern. Key Groups para validar Signed URLs.
Amazon S3
Almacenamiento privado de objetos
Bucket con BlockPublicAccess habilitado en las 4 opciones. Bucket Policy que únicamente permite s3:GetObject al service principal cloudfront.amazonaws.com con condición AWS:SourceArn del distribution. SSE-KMS para cifrado en reposo.
CloudFront Origin Access Control (OAC)
Autenticación SigV4 de CloudFront hacia S3
Reemplaza el OAI legacy. Firma todas las solicitudes de CloudFront a S3 con SigV4, soporta SSE-KMS, compatible con S3 en todas las regiones incluyendo us-east-1.
CloudFront Key Pairs
Firma de Signed URLs
RSA-2048 key pair. La clave privada se almacena en AWS Secrets Manager. La clave pública se sube a CloudFront como Trusted Key Group. El backend extrae la privada en runtime para firmar URLs.
AWS Secrets Manager
Almacenamiento seguro de la clave privada CloudFront
La clave privada RSA se almacena como SecretString. Rotación automática configurable. El backend accede via SDK con un IAM Role con permisos secretsmanager:GetSecretValue limitado a ese secreto específico.
AWS KMS
Cifrado del bucket S3 (SSE-KMS)
Customer Managed Key (CMK) para cifrado at-rest. Permite auditoría de acceso a datos vía CloudTrail + KMS key policy. OAC automáticamente incluye kms:Decrypt en sus permisos.
Decisiones Técnicas (Trade-offs)
OAC vs OAI (Origin Access Identity)
Elegido
Origin Access Control (OAC)
Alternativas
- —Origin Access Identity (OAI) — descontinuado como opción preferida
Razón
OAC es el mecanismo recomendado por AWS desde 2022. Soporta SSE-KMS (OAI no puede descifrar objetos KMS nativamente), usa SigV4 más moderno, y es compatible con S3 Multi-Region Access Points. OAI está en modo legacy sin nuevas características.
Signed URLs vs Signed Cookies
Elegido
Signed URLs
Alternativas
- —Signed Cookies — mejor para streaming de múltiples archivos bajo el mismo path
Razón
Signed URLs son ideales para acceso a archivos individuales y escenarios de descarga directa. Cada URL es auto-contenida con su firma, no requiere configuración de cookies en el cliente. Signed Cookies son preferibles para plataformas de video streaming donde se necesita acceso a múltiples objetos con un solo token.
TTL de la Signed URL
Elegido
900 segundos (15 minutos)
Alternativas
- —5 min — muy agresivo, falla en conexiones lentas
- —1 hora — más UX pero mayor ventana de exposición
- —24 horas — inaceptable para archivos sensibles
Razón
Balance entre UX (tiempo suficiente para descargar archivos grandes) y seguridad (ventana de exposición mínima si la URL se filtra). Para downloads de archivos muy grandes (>1GB) se puede extender a 1 hora.
Seguridad
- ✓S3 Block Public Access habilitado en las 4 opciones: BlockPublicAcls, IgnorePublicAcls, BlockPublicPolicy, RestrictPublicBuckets.
- ✓Bucket Policy con condición AWS:SourceArn: solo el ARN exacto del distribution puede hacer GetObject.
- ✓Signed URLs con expiración corta (15 min): ventana de exposición mínima ante filtración.
- ✓CloudFront con HTTPS-only (redirect HTTP → HTTPS) y TLS 1.2 mínimo.
- ✓HSTS (Strict-Transport-Security) headers en las respuestas CloudFront.
- ✓KMS CMK con key policy que audita todos los accesos vía CloudTrail.
- ✓Clave privada RSA solo en Secrets Manager, nunca en variables de entorno ni código.
- ✓CloudTrail logging para todas las operaciones S3 y KMS.
Escalabilidad
- ↑CloudFront escala automáticamente a cualquier nivel de tráfico — no hay límites de instancias ni capacidad que gestionar.
- ↑Objetivo de Cache Hit Rate >80%: reduce carga en S3 y latencia para usuarios recurrentes.
- ↑Cache TTL configurable por tipo de objeto (ej: thumbnails 7 días, PDFs 1 hora).
- ↑S3 soporta hasta 5500 GET requests/segundo por prefijo — particionado de prefijos si se supera.
- ↑Signed URL generation es O(1) — operación criptográfica local, no requiere llamadas adicionales a AWS después de obtener la clave.
- ↑Multi-origin posible: CloudFront puede servir desde múltiples S3 en distintas regiones para DR.
Estimación de Costos
| Servicio / Concepto | Estimado |
|---|---|
| CloudFront — Data Transfer Out | $0.0085/GB |
| CloudFront — HTTPS Requests | $0.0100/10,000 requests |
| S3 — Almacenamiento | $0.023/GB/mes |
| S3 — GET Requests (desde CloudFront) | $0.0004/1,000 requests |
| KMS — API Calls | $0.03/10,000 requests |
| Secrets Manager | $0.40/secreto/mes + $0.05/10,000 API calls |
Snippets de Código
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "6.45.0"
}
}
required_version = ">= 1.4.0"
}
provider "aws" {
region = "us-east-1"
profile = "leader-developer-personal"
}
#################################################
# VARIABLE
#################################################
variable "env" {
type = string
}
#################################################
# DATA SOURCES
#################################################
data "aws_caller_identity" "current" {}
#################################################
# KMS KEY
#################################################
resource "aws_kms_key" "s3" {
description = "KMS key for S3 encryption"
enable_key_rotation = true
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "EnableRootAccess"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
}
Action = "kms:*"
Resource = "*"
},
{
Sid = "AllowCloudFrontViaS3"
Effect = "Allow"
Principal = {
Service = "cloudfront.amazonaws.com"
}
Action = [
"kms:Decrypt",
"kms:GenerateDataKey"
]
Resource = "*"
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.cdn.arn
}
}
}
]
})
}
resource "aws_kms_alias" "s3" {
name = "alias/s3-private-assets-${var.env}"
target_key_id = aws_kms_key.s3.key_id
}
#################################################
# S3 BUCKET
#################################################
resource "aws_s3_bucket" "assets" {
bucket = "my-private-assets-${var.env}-2026-demo"
}
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
block_public_acls = true
ignore_public_acls = true
block_public_policy = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.s3.arn
}
bucket_key_enabled = true
}
}
#################################################
# CLOUDFRONT PUBLIC KEY & KEY GROUP
#################################################
resource "aws_cloudfront_public_key" "main" {
name = "cdn-public-key"
comment = "Public key for signed URLs"
encoded_key = file("${path.module}/public_key.pem")
}
resource "aws_cloudfront_key_group" "signed_urls" {
name = "signed-url-key-group"
items = [aws_cloudfront_public_key.main.id]
}
#################################################
# ORIGIN ACCESS CONTROL
#################################################
resource "aws_cloudfront_origin_access_control" "oac" {
name = "s3-oac"
description = "OAC for private S3 bucket"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
#################################################
# CLOUDFRONT DISTRIBUTION
#################################################
resource "aws_cloudfront_distribution" "cdn" {
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
price_class = "PriceClass_100"
origin {
domain_name = aws_s3_bucket.assets.bucket_regional_domain_name
origin_id = "s3-private-assets"
origin_access_control_id = aws_cloudfront_origin_access_control.oac.id
}
default_cache_behavior {
target_origin_id = "s3-private-assets"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
trusted_key_groups = [aws_cloudfront_key_group.signed_urls.id]
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
min_ttl = 0
default_ttl = 86400
max_ttl = 604800
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
}
#################################################
# BUCKET POLICY
#################################################
resource "aws_s3_bucket_policy" "assets" {
depends_on = [
aws_s3_bucket_public_access_block.assets
]
bucket = aws_s3_bucket.assets.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowCloudFrontAccess"
Effect = "Allow"
Principal = {
Service = "cloudfront.amazonaws.com"
}
Action = ["s3:GetObject"]
Resource = ["${aws_s3_bucket.assets.arn}/*"]
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.cdn.arn
}
}
}
]
})
}import { getSignedUrl } from '@aws-sdk/cloudfront-signer';
import fs from 'fs';
import { CLOUDFRONT_KEYPAIR_ID, CLOUDFRONT_PRIVATE_KEY } from '../../../config.js';
async function firmarUrl(url, expiresInSeconds = 86400) {
console.info(CLOUDFRONT_PRIVATE_KEY);
const signedUrl = await getSignedUrl({
url,
dateLessThan: new Date(Date.now() + expiresInSeconds * 1000),
privateKey: fs.readFileSync(CLOUDFRONT_PRIVATE_KEY),
keyPairId: CLOUDFRONT_KEYPAIR_ID,
});
return signedUrl;
}
export default firmarUrl;