Contents

Tiered Access To CloudFront Content With Self-Signed Cookies

This blog post is a follow-up to our previous post, where we implemented tiered access to S3 data using presigned URLs.

In most production applications, CloudFront is used to serve static content to users. In this post, we will explore how to implement restricted access when serving content through CloudFront.

You can find the complete example here.

What is CloudFront?

In simple terms, CloudFront is a content delivery network (CDN) managed by AWS. A CDN is a network of servers deployed close to end users, serving as a caching layer to improve content delivery speed and reliability.

When a user requests content that is not present on the edge server, the edge server pulls the content from your backend and caches it. If the content is already present on the edge server, it is returned directly to the user without contacting your backend.

Benefits and Limitations

Benefits:

  • Offloading Backend: Reduces the load on your backend servers by caching content at the edge.
  • Lower Latency: Delivers content faster to users by serving it from a location closer to them.
  • Support for Different Origins: Can fetch content from multiple backend sources, including S3 buckets, EC2 instances, and custom origins.
  • Reliable Traffic Between Edge Locations and Origin: Ensures consistent and reliable data transfer between edge locations and your origin servers.
  • Support for Static Content and Video Streaming: Efficiently handles both static files (like images and CSS) and video streaming, improving the user experience.

Limitations:

  • Stale Content: Cached content might become outdated, requiring careful management of cache invalidation and refresh strategies.
  • Cost: Additional costs for data transfer and request handling, which can add up depending on usage.
  • Caching Strategy Needs Tweaking: Requires fine-tuning of caching policies to balance performance and content freshness effectively.

Restricting Access

CloudFront generally supports two ways to restrict access to content: signed URLs and signed cookies. Signed URLs are used to grant access to a single file, while signed cookies are used for accessing multiple files.

For simpler authorization mechanisms, you can also use Lambda@Edge functions. However, these functions have limited package sizes and can introduce additional overhead.

Since we already implemented signed URLs with S3 in our previous post, this blog post will focus on using signed cookies for restricting access to CloudFront content.

Architecture

/tiered-access-to-cloudfront-content-with-self-signed-cookies/architecture.png
Fig 1. Architecture

To demonstrate tiered access, CloudFront is configured to serve both public and private content from an S3 origin using path-based routing. Private content is secured with signed cookies, utilizing trusted key groups and public key cryptography.

When deploying CloudFront, we provide a public key that CloudFront uses to validate cookie data. The private key is stored in AWS Secrets Manager and accessed by a Lambda function that creates signed cookies.

CloudFront and the Api Gateway need to share a domain to avoid client-side security issues when issuing cookies. For this purpose, we use Route 53 for DNS resolution and AWS Certificate Manager for managing TLS certificates for our custom domains.

Here’s the flow for accessing private content:

  1. User Authentication: The user authenticates with Cognito and retrieves an authentication token.
  2. Request Signed Cookie: The user uses the authentication token to request a signed cookie from a secure API, implemented with API Gateway and a Lambda function.
  3. Lambda Function Operation:
  • The Lambda function retrieves the private key from Secrets Manager.
  • It generates the cookie data, including the policy, key ID, and signature.
  1. Access Private Data: With the signed cookie, the user requests private data from CloudFront.
  2. CloudFront Validation: CloudFront validates the cookie’s signature. If the signature is correct, CloudFront returns the requested data to the user.

Key Management

CloudFront uses public key cryptography and trusted key groups to sign and validate cookies. Unfortunately, CloudFront trusted key groups do not integrate with AWS KMS at this time, so key management must be handled manually.

CloudFront trusted key groups will hold the public key and are deployed with infrastructure configuration. Be sure to follow security best practices to secure your CI workflow.

Public and private keys are RSA 2048 keys in PKCS#1 format. Keys are generated as part of Terraform. If you are generating them yourself, use the following commands:

1
2
3
# Generate RSA key pair in PKCS#1 format
openssl genrsa -traditional -out private_key.pem 2048 && \
openssl rsa -pubout -in private_key.pem -out public_key.pem

Infrastructure

The infrastructure is provisioned using Terraform.

Certificates and Encryption Keys

CloudFront content will be available at https://DOMAIN, and the API will be accessible at https://api.DOMAIN.

In the following steps, we will provision a certificate from AWS Certificate Manager. We will also create an RSA key pair and upload the private key to AWS Secrets Manager.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
module "acm" {
  source  = "terraform-aws-modules/acm/aws"
  version = "~> 4.0"

  domain_name = var.domain_name
  zone_id     = var.route53_zone_id
  subject_alternative_names = [
    var.domain_name,
    "api.${var.domain_name}"
  ]
}


resource "tls_private_key" "example" {
  algorithm = "RSA"
  rsa_bits  = 2048
}

resource "aws_secretsmanager_secret" "private_key" {}
resource "aws_secretsmanager_secret_version" "private_key_value" {
  secret_id     = aws_secretsmanager_secret.private_key.id
  secret_string = tls_private_key.example.private_key_pem
}

CloudFront Configuration

The CloudFront distribution is configured with one S3 origin and two cache behaviors: one for public content and one for private content. The private content is secured using trusted key groups created from the public key generated in the previous step.

The S3 origin bucket policy is modified to allow access only from the CloudFront distribution. This prevents users from directly accessing the origin and bypassing the security policies implemented in CloudFront.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
module "cdn" {
  source  = "terraform-aws-modules/cloudfront/aws"
  version = "3.4.0"

  comment             = "Example Cloudfront Distribution"
  enabled             = true
  is_ipv6_enabled     = true
  retain_on_delete    = false
  wait_for_deployment = true

  aliases = [var.domain_name]

  create_origin_access_control = true
  origin_access_control = {
    s3_oac = {
      description      = "CloudFront access to S3"
      origin_type      = "s3"
      signing_behavior = "always"
      signing_protocol = "sigv4"
    }
  }

  origin = {
    media_bucket = {
      domain_name           = module.media_bucket.s3_bucket_bucket_domain_name
      origin_access_control = "s3_oac"
    }
  }

  ordered_cache_behavior = [
    {
      target_origin_id       = "media_bucket"
      viewer_protocol_policy = "redirect-to-https"
      path_pattern           = "/private/*"

      allowed_methods = ["GET", "HEAD", "OPTIONS"]
      cached_methods  = ["GET", "HEAD"]
      compress        = true
      query_string    = true

      trusted_key_groups = [aws_cloudfront_key_group.example_key_group.id]

    }
  ]

  default_cache_behavior = {
    target_origin_id       = "media_bucket"
    viewer_protocol_policy = "redirect-to-https"

    path_pattern = "/public/*"

    allowed_methods = ["GET", "HEAD", "OPTIONS"]
    cached_methods  = ["GET", "HEAD"]
    compress        = true
  }

  viewer_certificate = {
    acm_certificate_arn = module.acm.acm_certificate_arn
    ssl_support_method  = "sni-only"
  }

}

resource "aws_route53_record" "record" {
  name    = var.domain_name
  type    = "A"
  zone_id = var.route53_zone_id

  alias {
    evaluate_target_health = true
    name                   = module.cdn.cloudfront_distribution_domain_name
    zone_id                = module.cdn.cloudfront_distribution_hosted_zone_id
  }
}


resource "aws_cloudfront_public_key" "example_key" {
  comment     = "example public key"
  name        = "example-key"
  encoded_key = tls_private_key.example.public_key_pem
}

resource "aws_cloudfront_key_group" "example_key_group" {
  comment = "example key group"
  items   = [aws_cloudfront_public_key.example_key.id]
  name    = "example-key-group"
}

module "media_bucket" {
  source  = "terraform-aws-modules/s3-bucket/aws"
  version = "4.1.2"

  # Allow deletion of non-empty bucket
  force_destroy = true

  server_side_encryption_configuration = {
    rule = {
      bucket_key_enabled = true

      apply_server_side_encryption_by_default = {
        sse_algorithm = "AES256"
      }
    }
  }
}

data "aws_iam_policy_document" "s3_policy" {

  # Origin Access Controls
  statement {
    actions   = ["s3:GetObject"]
    resources = ["${module.media_bucket.s3_bucket_arn}/*"]

    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }

    condition {
      test     = "StringEquals"
      variable = "aws:SourceArn"
      values   = [module.cdn.cloudfront_distribution_arn]
    }
  }
}

resource "aws_s3_bucket_policy" "bucket_policy" {
  bucket = module.media_bucket.s3_bucket_id
  policy = data.aws_iam_policy_document.s3_policy.json
}

Cognito Configuration

To facilitate user authentication, a Cognito user pool is configured with a default user that can be used to test the user flow.

Here is the Terraform configuration to set up the Cognito user pool, user pool client, and a test user:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
resource "aws_cognito_user_pool" "user_pool" {
  name = "my-user-pool"

  schema {
    attribute_data_type      = "String"
    developer_only_attribute = false
    mutable                  = true
    name                     = "birthdate"
    required                 = false

    string_attribute_constraints {
      max_length = "10"
      min_length = "4"
    }
  }
}

resource "aws_cognito_user_pool_client" "client" {
  name                = "my-user-pool-client"
  user_pool_id        = aws_cognito_user_pool.user_pool.id
  explicit_auth_flows = ["ALLOW_USER_PASSWORD_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"]

}

resource "aws_cognito_user" "test_user" {
  user_pool_id = aws_cognito_user_pool.user_pool.id
  username     = var.test_user_username
  password     = var.test_user_password

}

API Gateway

A regional API Gateway is configured with a Cognito authorizer and has one secure GET endpoint, signed-cookies.

Since there is no official Python example for issuing signed cookies, the Lambda function is implemented in Python. All relevant information is passed via environment variables, and permissions are updated to include pulling the private key from AWS Secrets Manager.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# Define the API Gateway
resource "aws_api_gateway_rest_api" "api_gateway" {
  name        = "my-api-gateway"
  description = "API Gateway for authorizing users and issuing signed cookies"

  endpoint_configuration {
    types = ["REGIONAL"]
  }
}

# Define the OAuth configuration
resource "aws_api_gateway_authorizer" "oauth_authorizer" {
  name                             = "oauth-authorizer"
  rest_api_id                      = aws_api_gateway_rest_api.api_gateway.id
  type                             = "COGNITO_USER_POOLS"
  identity_source                  = "method.request.header.Authorization"
  provider_arns                    = [aws_cognito_user_pool.user_pool.arn]
  authorizer_result_ttl_in_seconds = 300
}

# Configure the API Gateway to trigger the Lambda function and protect it using OAuth and Cognito
resource "aws_api_gateway_resource" "resource" {
  rest_api_id = aws_api_gateway_rest_api.api_gateway.id
  parent_id   = aws_api_gateway_rest_api.api_gateway.root_resource_id
  path_part   = "signed-cookies"
}

resource "aws_api_gateway_method" "method" {
  rest_api_id   = aws_api_gateway_rest_api.api_gateway.id
  resource_id   = aws_api_gateway_resource.resource.id
  http_method   = "GET"
  authorization = "COGNITO_USER_POOLS"
  authorizer_id = aws_api_gateway_authorizer.oauth_authorizer.id
}

resource "aws_api_gateway_integration" "integration" {
  rest_api_id             = aws_api_gateway_rest_api.api_gateway.id
  resource_id             = aws_api_gateway_resource.resource.id
  http_method             = aws_api_gateway_method.method.http_method
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = module.lambda_function.lambda_function_invoke_arn
}

resource "aws_api_gateway_deployment" "deployment" {
  depends_on  = [aws_api_gateway_integration.integration]
  rest_api_id = aws_api_gateway_rest_api.api_gateway.id
}

resource "aws_api_gateway_stage" "prod" {
  deployment_id = aws_api_gateway_deployment.deployment.id
  rest_api_id   = aws_api_gateway_rest_api.api_gateway.id
  stage_name    = "prod"
}


data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

# Lambda
resource "aws_lambda_permission" "apigw_lambda" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = module.lambda_function.lambda_function_name
  principal     = "apigateway.amazonaws.com"

  source_arn = "arn:aws:execute-api:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:${aws_api_gateway_rest_api.api_gateway.id}/*/${aws_api_gateway_method.method.http_method}${aws_api_gateway_resource.resource.path}"
}

module "lambda_function" {
  source  = "terraform-aws-modules/lambda/aws"
  version = "7.7.0"

  function_name = "lambda-cloudfront-signer"
  description   = "Lambda function for issuing signed cookies"
  handler       = "code.lambda_signer.lambda_handler"
  runtime       = "python3.12"

  build_in_docker = true
  source_path     = "../api/cloudfront-signer"

  environment_variables = {
    REGION_NAME       = data.aws_region.current.name
    SM_PRIVATE_KEY_ID = aws_secretsmanager_secret.private_key.id
    PRIVATE_KEY_ID    = aws_cloudfront_public_key.example_key.id
    CDN_DOMAIN_NAME   = var.domain_name
    CDN_PRIVATE_PATH  = "https://${var.domain_name}/private/*"
  }

  attach_policy_statements = true
  policy_statements = {
    sm = {
      effect    = "Allow",
      actions   = ["secretsmanager:GetSecretValue"],
      resources = [aws_secretsmanager_secret.private_key.id]
    }
  }
}

Configuration for api gateway custom domain is below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
resource "aws_api_gateway_domain_name" "example" {
  domain_name              = "api.${var.domain_name}"
  regional_certificate_arn = module.acm.acm_certificate_arn

  endpoint_configuration {
    types = ["REGIONAL"]
  }
}


resource "aws_route53_record" "example" {
  name    = aws_api_gateway_domain_name.example.domain_name
  type    = "A"
  zone_id = var.route53_zone_id

  alias {
    evaluate_target_health = true
    name                   = aws_api_gateway_domain_name.example.regional_domain_name
    zone_id                = aws_api_gateway_domain_name.example.regional_zone_id
  }
}

resource "aws_api_gateway_base_path_mapping" "example" {
  api_id      = aws_api_gateway_rest_api.api_gateway.id
  stage_name  = aws_api_gateway_stage.prod.stage_name
  domain_name = aws_api_gateway_domain_name.example.domain_name
}

Terraform configuration to set up a custom domain for the API Gateway is below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
resource "aws_api_gateway_domain_name" "example" {
  domain_name              = "api.${var.domain_name}"
  regional_certificate_arn = module.acm.acm_certificate_arn

  endpoint_configuration {
    types = ["REGIONAL"]
  }
}


resource "aws_route53_record" "example" {
  name    = aws_api_gateway_domain_name.example.domain_name
  type    = "A"
  zone_id = var.route53_zone_id

  alias {
    evaluate_target_health = true
    name                   = aws_api_gateway_domain_name.example.regional_domain_name
    zone_id                = aws_api_gateway_domain_name.example.regional_zone_id
  }
}

resource "aws_api_gateway_base_path_mapping" "example" {
  api_id      = aws_api_gateway_rest_api.api_gateway.id
  stage_name  = aws_api_gateway_stage.prod.stage_name
  domain_name = aws_api_gateway_domain_name.example.domain_name
}

Lambda issuer function

The Lambda function has an rsa dependency that needs to be packaged with the code. Below are the helper functions for retrieving the private key and creating cookie values.

Helper Functions

These helper functions are used to retrieve the private key from AWS Secrets Manager and to create signed cookie values.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import functools
import logging
from datetime import datetime

import boto3
import rsa
from botocore.exceptions import ClientError
from botocore.signers import CloudFrontSigner

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)


def get_sm_secret(secret_name: str, region_name: str) -> str:

    logger.info(f"Retrieving secrt: {secret_name}")

    session = boto3.session.Session()
    client = session.client(service_name="secretsmanager", region_name=region_name)

    try:
        get_secret_value_response = client.get_secret_value(SecretId=secret_name)
    except ClientError as e:
        raise e

    secret = get_secret_value_response["SecretString"]
    return secret


class CloudfrontUtils:

    def __init__(self, private_key_id: str, priv_key_value: str):
        logger.info("Initializing CloudfrontUtils")
        self._key_id = private_key_id
        self._priv_key = rsa.PrivateKey.load_pkcs1(priv_key_value.encode("utf8"))

        self._rsa_signer = functools.partial(
            rsa.sign, priv_key=self._priv_key, hash_method="SHA-1"
        )
        self._cf_signer = CloudFrontSigner(self._key_id, self._rsa_signer)

    def generate_signed_cookies(self, url: str, expire_at: datetime) -> dict[str, str]:

        logger.info("Generating signed cookies")
        policy = self._cf_signer.build_policy(url, expire_at).encode("utf8")
        policy_64 = self._cf_signer._url_b64encode(policy).decode("utf8")

        signature = self._rsa_signer(policy)
        signature_64 = self._cf_signer._url_b64encode(signature).decode("utf8")
        cookie_values = {
            "CloudFront-Policy": policy_64,
            "CloudFront-Signature": signature_64,
            "CloudFront-Key-Pair-Id": self._key_id,
        }

        logger.debug(f"Signed cookie values: {cookie_values}")
        return cookie_values

Lambda Function

The CloudfrontUtils class is instantiated in the global scope to reduce the number of requests to AWS Secrets Manager.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import os
from datetime import datetime, timedelta

from .helpers import CloudfrontUtils, get_sm_secret

# global variables
CDN_DOMAIN_NAME = os.environ["CDN_DOMAIN_NAME"]
CDN_PRIVATE_PATH = os.environ["CDN_PRIVATE_PATH"]
REGION_NAME = os.environ["REGION_NAME"]
SECRET_NAME = os.environ["SM_PRIVATE_KEY_ID"]
PRIVATE_KEY_ID = os.environ["PRIVATE_KEY_ID"]

CF_UTILS = CloudfrontUtils(
    private_key_id=PRIVATE_KEY_ID,
    priv_key_value=get_sm_secret(secret_name=SECRET_NAME, region_name=REGION_NAME),
)


def lambda_handler(event: dict, context: object) -> dict:

    expire_at = datetime.now() + timedelta(minutes=5)
    signed_cookies = CF_UTILS.generate_signed_cookies(
        url=CDN_PRIVATE_PATH, expire_at=expire_at
    )

    return {
        "statusCode": 200,
        "multiValueHeaders": {
            "Set-Cookie": [
                f"{key}={value};Path=/;Secure;HttpOnly;Domain={CDN_DOMAIN_NAME}"
                for key, value in signed_cookies.items()
            ],
        },
    }

Complete Example

You can find the complete example with instructions here.

Conclusion

In this blog post, we have explored how to implement restricted access to CloudFront content using signed cookies. We covered a sample architecture with code examples and discussed some of the intricacies of the implementation .

Happy engineering!