Providing private content securely and swiftly is a very common challenge for any application which is on the internet. Whether you’re building a SaaS dashboard, a paid content platform or an enterprise portal, it is important that only authenticated users are able to access sensitive documents or assets, while providing best download performance.
This is where a Content Delivery Network service comes into play — on AWS, that’s CloudFront. CloudFront signed cookies authorization becomes a powerful design pattern. Instead of enforcing custom authorization logic on your backend, generating a pre-signed URL and sending it back to the user, here authentication happens in the application layer and authorization is handled at the edge by CloudFront.
By combining session-based authorization AWS patterns with edge security, you gain lower latency, improved scalability, caching and stronger security of your application. This article walks you through a clean, minimal, AWS-based architecture that shows you the thought process and the code to protect and provide private content to your users by using CloudFront signed cookies.
Code changes are in GitHub and are available by clicking on the link here.
High-Level Architecture Overview and AWS Services Used
At a high level, this pattern enables users to authenticate once via AWS Lambda and Cognito to then access application access with the best download performance. The browser or any other client of you application receives a HTTP-only cookie that CloudFront validates on every request, without calling any other backend services.
The core idea is simple:
- Provide Authentication endpoints via AWS API Gateway and authenticate users using AWS Lambda and Amazon Cognito
- Mint signed cookies via a lightweight backend
- Enforce Amazon CloudFront authorization at the edge
- Restrict the origin so content is never publicly accessible
Services which are crucial for this pattern are:
Amazon CloudFront + Amazon S3
Amazon CloudFront is a Content Delivery Network provided by AWS, which acts as the central enforcement point. It validates provided signed cookies and decides if the user has access to the assets available at the edge.
Amazon S3 is a service used to store objects and files inside buckets. The bucket blocks all public access and can only be reached through CloudFront, enabling CloudFront secure S3 access.
Amazon Cognito
Amazon Cognito handles user authentication. It verifies user identity and issues JWT tokens.
Amazon API Gateway + AWS Lambda
We use Amazon API Gateway to open up our Authentication Lambdas to the internet.
AWS Lambda runs a minimal, stateless function. Its single responsibility is to validate the incoming email and password and if the user is registered, it provides the signed cookies which enable the user to access private data. This keeps the system simple, auditable, and secure.
AWS Secrets Manager
AWS Secrets Manager securely stores the RSA private key used to sign CloudFront cookies. The key never leaves AWS-managed infrastructure.
End-to-End Request Flow and Architecture Diagram
The flow for the client of your application is the following:
- User registers to the platform via Cognito and Register Lambda
- User logs in and Login Lambda mints AWS CloudFront signed cookies
- Browser stores cookies securely
- When needed, browser requests private content from CloudFront
- CloudFront validates the provided cookies
- Authorized requests reach the private S3 bucket
- Load the assets at lightning-fast speed!
Authentication Stack
First, we need to initialize our Cognito resources, like user pool, to be able to register and login users via our Lambdas, which we are going to define later. The following AWS CDK code creates the necessary resources in Cognito:
# Cognito User Pool
user_pool = cognito.UserPool(
self,
"UserPool",
user_pool_name="UserPool",
auto_verify=cognito.AutoVerifiedAttrs(email=True),
sign_in_aliases=cognito.SignInAliases(email=True),
self_sign_up_enabled=True,
password_policy=cognito.PasswordPolicy(
min_length=8,
require_lowercase=True,
require_uppercase=True,
require_digits=True,
require_symbols=False,
),
standard_attributes=cognito.StandardAttributes(
email=cognito.StandardAttribute(required=True, mutable=True),
),
removal_policy=RemovalPolicy.DESTROY,
)
user_pool_client = cognito.UserPoolClient(
self,
"UserPoolClient",
user_pool=user_pool,
user_pool_client_name="WebApp",
generate_secret=False,
auth_flows=cognito.AuthFlow(
user_srp=True,
user_password=True,
custom=False,
admin_user_password=False,
),
prevent_user_existence_errors=True,
)
Generating and Saving the RSA Key-pair
To enable the signing process of the cookies, we need to generate the RSA key-pair, create a variable that will hold the public key and make it eligible for usage with CloudFront and save the private key in the AWS Secrets Manager.
Important note — this pattern creates the RSA key-pair every time you try to deploy or update the stack. This is made on purpose, as the pattern itself is made to get you quickly off the ground. The best way is to generate the keys in a separate Python script or via your terminal, load them in the CDK code and deploy to AWS.
# Generate private key
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend()
)
# Serialize private key to PEM format
private_key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
).decode('utf-8')
# Extract public key and serialize to PEM format
public_key = private_key.public_key()
public_key_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')
cf_public_key = cloudfront.PublicKey(
self,
"CloudFrontPublicKey",
encoded_key=public_key_pem,
comment="Public key for CloudFront signed cookies",
)
cf_key_group = cloudfront.KeyGroup(
self,
"CloudFrontKeyGroup",
items=[cf_public_key],
comment="Key group for signed cookie validation",
)
# Secrets Manager - Private Key Storage
private_key_secret = secretsmanager.Secret(
self,
"CloudFrontPrivateKeySecret",
description="CloudFront private key for signing cookies (PEM format)",
secret_string_value=SecretValue.unsafe_plain_text(private_key_pem),
removal_policy=RemovalPolicy.DESTROY,
)
Now, that we have our encryption keys and all variables initialized, now it’s time to create the CloudFront distribution itself.
Creating S3 bucket, CloudFront Distribution and Securing the S3 Bucket
The S3 bucket is fully locked down:
- All public access is blocked
- No direct access from the internet is allowed
- Requests are accepted only from CloudFront
This is achieved using Origin Access Control, enabling AWS private S3 bucket CloudFront OAC patterns. Even if someone discovers the S3 bucket name, they cannot retrieve objects directly.
The CloudFront key group created in the previous step is referenced here to associate the public key from our key pair with the newly created distribution, allowing CloudFront to recognize and use it correctly.
In addition, it’s worth mentioning that assets and object that have the prefix private/ in their key are protected via the signed cookie mechanism, while all other assets in the bucket are publicly available via the CloudFront distribution.
This design ensures your private content delivery AWS strategy has no weak points.
# S3 Bucket for Private Assets
private_assets_bucket = s3.Bucket(
self,
"PrivateAssetsBucket",
block_public_access=s3.BlockPublicAccess.BLOCK_ALL,
encryption=s3.BucketEncryption.S3_MANAGED,
enforce_ssl=True,
removal_policy=RemovalPolicy.DESTROY,
auto_delete_objects=True,
versioned=False,
)
# CloudFront Origin Access Control (OAC)
oac = cloudfront.CfnOriginAccessControl(
self,
"S3OriginAccessControl",
origin_access_control_config=cloudfront.CfnOriginAccessControl.OriginAccessControlConfigProperty(
name=f"{construct_id}-S3-OAC",
origin_access_control_origin_type="s3",
signing_behavior="always",
signing_protocol="sigv4",
description="OAC for private S3 bucket access via CloudFront",
),
)
# CloudFront Distribution
# Create the S3 origin without OAC first
s3_origin = origins.S3BucketOrigin.with_origin_access_control(
private_assets_bucket
)
# Default behavior - public content (no signed cookies required)
default_behavior = cloudfront.BehaviorOptions(
origin=s3_origin,
viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowed_methods=cloudfront.AllowedMethods.ALLOW_GET_HEAD,
cached_methods=cloudfront.CachedMethods.CACHE_GET_HEAD,
cache_policy=cloudfront.CachePolicy.CACHING_OPTIMIZED,
compress=True,
)
# Private behavior - requires signed cookies
private_behavior = cloudfront.BehaviorOptions(
origin=s3_origin,
viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowed_methods=cloudfront.AllowedMethods.ALLOW_GET_HEAD,
cached_methods=cloudfront.CachedMethods.CACHE_GET_HEAD,
cache_policy=cloudfront.CachePolicy.CACHING_OPTIMIZED,
compress=True,
trusted_key_groups=[cf_key_group],
)
distribution = cloudfront.Distribution(
self,
"PrivateContentDistribution",
default_behavior=default_behavior,
additional_behaviors={
"private/*": private_behavior,
},
comment="Distribution for private S3 content with signed cookies",
price_class=cloudfront.PriceClass.PRICE_CLASS_100,
enabled=True,
)
# S3 Bucket Policy for CloudFront OAC
private_assets_bucket.add_to_resource_policy(
iam.PolicyStatement(
sid="AllowCloudFrontServicePrincipalReadOnly",
effect=iam.Effect.ALLOW,
principals=[iam.ServicePrincipal("cloudfront.amazonaws.com")],
actions=["s3:GetObject"],
resources=[private_assets_bucket.arn_for_objects("*")],
conditions={
"StringEquals": {
"AWS:SourceArn": f"arn:aws:cloudfront::{self.account}:distribution/{distribution.distribution_id}"
}
},
)
)
AWS Lambdas + API Gateway
Finally, we have our 2 Lambdas and API Gateway which enables the user to register and login into our platform to receive the necessary credentials, like the signed cookie and idToken in case you are going to use Cognito Authorizer on your other stacks.
# Lambdas
# IAM Policy for Lambda functions to access Cognito
cognito_policy = iam.PolicyStatement(
effect=iam.Effect.ALLOW,
actions=[
"cognito-idp:SignUp",
"cognito-idp:AdminConfirmSignUp",
"cognito-idp:InitiateAuth",
],
resources=[user_pool.user_pool_arn],
)
# Register User Lambda Function
register_lambda = _lambda.Function(
self,
"RegisterUserLambda",
runtime=_lambda.Runtime.PYTHON_3_12,
handler="lambda_handler.handler",
code=_lambda.Code.from_asset(
"./lambda/Register",
bundling={
"image": _lambda.Runtime.PYTHON_3_12.bundling_image,
"command": ["bash", "-c", "pip install aws-lambda-powertools -t /asset-output && cp -r . /asset-output"],
},
),
environment={
"POWERTOOLS_SERVICE_NAME": "authentication",
"USER_POOL_ID": user_pool.user_pool_id,
"USER_POOL_CLIENT_ID": user_pool_client.user_pool_client_id,
"ALLOWED_ORIGIN": allowed_cors_origin,
},
timeout=Duration.seconds(30),
)
register_lambda.add_to_role_policy(cognito_policy)
# Login User Lambda Function
login_lambda = _lambda.Function(
self,
"LoginUserLambda",
runtime=_lambda.Runtime.PYTHON_3_12,
handler="lambda_handler.handler",
code=_lambda.Code.from_asset(
"./lambda/Login",
bundling={
"image": _lambda.Runtime.PYTHON_3_12.bundling_image,
"command": [
"bash",
"-lc",
"pip install --platform manylinux2014_x86_64 --only-binary=:all: --no-cache-dir --upgrade "
"-t /asset-output aws-lambda-powertools 'cryptography>=41' "
"&& cp -au . /asset-output"
],
},
),
environment={
"POWERTOOLS_SERVICE_NAME": "authentication",
"USER_POOL_ID": user_pool.user_pool_id,
"USER_POOL_CLIENT_ID": user_pool_client.user_pool_client_id,
"ALLOWED_ORIGIN": allowed_cors_origin,
"PRIVATE_KEY_SECRET_ARN": private_key_secret.secret_arn,
"CLOUDFRONT_DOMAIN": distribution.distribution_domain_name,
"KEY_PAIR_ID": cf_public_key.public_key_id,
"COOKIE_TTL_SECONDS": str(cookie_ttl_seconds),
"COOKIE_DOMAIN": cookie_domain,
"COOKIE_SAME_SITE": same_site,
},
timeout=Duration.seconds(30),
memory_size=256,
)
login_lambda.add_to_role_policy(cognito_policy)
private_key_secret.grant_read(login_lambda)
# API Gateway REST API
api = apigw.RestApi(
self,
"AuthApi",
rest_api_name="Auth and Cookie API",
description="API for authentication and CloudFront signed cookies",
deploy=True,
deploy_options=apigw.StageOptions(stage_name="v1"),
default_cors_preflight_options=apigw.CorsOptions(
allow_origins=[allowed_cors_origin] if allowed_cors_origin != "*" else apigw.Cors.ALL_ORIGINS,
allow_methods=["POST", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-Amz-Date", "X-Api-Key"],
allow_credentials=True,
),
)
# API Gateway Lambda Integration
register_integration = apigw.LambdaIntegration(register_lambda)
login_integration = apigw.LambdaIntegration(login_lambda)
# API Gateway Resources and Methods
api.root.add_resource("register").add_method("POST", register_integration)
api.root.add_resource("login").add_method("POST", login_integration)
After successful registration via the Register Lambda, when the user tries to login, they invoke the Login Lambda. This function is intentionally minimal:
- Validate the authenticated request context
- Retrieve the RSA private key from Secrets Manager
- Generate CloudFront signed cookies architecture values
- Return the cookies to the browser as HTTP-only cookies
Because the cookies are HTTP-only, they cannot be accessed by JavaScript, reducing the risk of XSS attacks. This is a critical best practice when implementing secure content delivery using CloudFront.
Security Best Practices
To harden your CloudFront signed cookies authorization setup:
- Use short-lived cookies
- Rotate RSA keys regularly
- Grant least-privilege IAM permissions
- Enable logging and monitoring at CloudFront and S3
These practices strengthen trust and align with AWS security recommendations.
By default, the cookie will expire in 600 seconds or 10 minutes after it’s created. That’s something you can modify inside the CDK code on your own, and also you can configure CORS settings as well.
Testing the pattern
I’ve uploaded 2 images from the AWS console into the S3 bucket - one is a picture of a car and it’s S3 key is private/gt3.jpg , while the other image is of Manhattan in New York City and it’s key is public/Above_gotham.jpg .
We are going to do 3 tests:
- try and fetch the
private/gt3.jpgimage without the signed cookie - we expect this request to be blocked - try and fetch the same image but with the signed cookie - we expect this to pass
- try and fetch the image of Manhattan, which we expect to pass since it’s a publicly available asset via the CloudFront distribution URL
I’ve used Postman to test this out and here is the example of the failed request, where the signed cookie is required:
Now, we are going to sign in, get the cookie, put it in the Cookie header and send the request again:
Finally, let’s try and fetch the publicly available image:
This is proof that our pattern works as expected — all assets and objects which are inside the private/ folder in S3 are protected and users need to be signed in to access them, while some objects can still be accessed publicly if needed.
When This Pattern May Not Be the Right Fit
While CloudFront signed cookies offer very powerful benefits, they also introduce tradeoffs which are worth considering.
This approach works best for content which doesn’t change often and has predictable access patterns, like videos, images and documents. For highly dynamic content, that changes per-request and/or requires real-time authorization, the edge-based model becomes limiting since you cannot revoke the cookie session in progress without cookie expiration, as it’s valid until it expires.
Additionally, this pattern adds architectural complexity — you need to manage RSA key pairs, coordinate cookie domains and if there’s a bug, you have multiple AWS services to look into.
If you have a smaller application and don’t really mind the longer download times and want less complex architecture for your application, a simpler approach would be to have a Lambda which handles pre-signing process of the S3 objects, as that is simpler and easier to maintain.
Conclusion
This architecture demonstrates how authentication happens by using just a few AWS services, while authorization is enforced at the edge by CloudFront. By combining Cognito, API Gateway, Lambda, and CloudFront signed cookies, you create a robust, scalable, and secure system for private content delivery AWS.
If you’re designing modern cloud systems, adopting CloudFront signed cookies authorization is a practical step toward better performance and stronger security.




Top comments (0)