← Arquitecturas/01-private-cdn
01Building☁️ AWS Architecture

CDN Privada Segura

Entrega global de archivos privados con CloudFront + S3 + Signed URLs y Origin Access Control.

Ver en GitHub▶ Demo (próximamente)
CloudFrontS3OACSigned URLsIAMKMSTerraform

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

Diagrama de arquitectura — CDN Privada Segura

Cómo Funciona

1

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.

2

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.

3

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.

4

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.

5

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.

6

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

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.

AWS

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.

AWS

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.

AWS

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

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

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 / ConceptoEstimado
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 — main.tf completohcl
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
          }
        }
      }
    ]
  })
}
NodeJS — Generación de Signed URLnodejs
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;