How CloudFront solves CORS problems
One domain means easier configuration and better security
CORS errors
Let's consider a fairly typical serverless setup! There is an S3 bucket containing the static files of the frontend code (SPA, typically) and an API behind an API Gateway. The problem is that they are under different domains, which becomes apparent during the first call to the API:
fetch(<API_URL>)
// or
fetch(<API_URL>, {credentials: "include"})
Access to XMLHttpRequest at '...' from origin '...' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Since the domains are different, the browser will treat this as a cross-origin request. And that means CORS headers are required on the API side.
The two most important headers are Access-Control-Allow-Origin
and Access-Control-Allow-Credentials
. The former is required for every cross-origin
request, the latter is only when the {credentials: "include"}
option is used.
Without the {credentials: "include"}
option the credentials, most notably, cookies, are not sent. And since authentication usually depends on cookies,
that means the user is anonymous in that case.
But if the credentials are included in the request, the API must respond with the Access-Control-Allow-Credentials: true
header. Without that, the
browser will throw an error.
Setting the CORS headers is not that hard, but it is still a chore. And since this is a security mechanism to protect your users an error in this process can open up CSRF attacks.
Why CloudFront
How could CloudFront help in this regard?
CORS is only needed for cross-origin requests, which means if the frontend and the backend are on the same domain this problem is non-existent. And this is exactly what CloudFront does.
With a CloudFront distribution, you can set up path-based routing to different backend services called origins. One origin can be the frontend bucket and
the other one the API Gateway, then you can map the former to /
and the latter to /api
. In this case, all requests are same-origin.
With this setup, sending a request to the API is a simple fetch
:
fetch("/api/")
This works, the browser sends credentials along with the request and no additional configuration is required on the API-side.
And since there is no CORS configuration, there is no risk of a misconfiguration opening an attack surface.
Setup
CloudFront configuration is divided into backends, called origins, and path mappings, called cache behaviors.
I'll use Terraform to provision a distribution:
resource "aws_cloudfront_distribution" "distribution" {
# ...
}
Frontend origin
The frontend code is deployed to an S3 bucket which means a special configuration item called s3_origin_config
.
origin {
domain_name = aws_s3_bucket.frontend_bucket.bucket_regional_domain_name
origin_id = "s3" # ID
s3_origin_config {
origin_access_identity = aws_cloudfront_origin_access_identity.OAI.cloudfront_access_identity_path
}
}
API origin
Integrating API Gateway to CloudFront can be tricky but the two key points here are to
extract the domain name from the invoke_url
and to make sure the stage name and the path pattern matches.
origin {
domain_name = replace(aws_api_gateway_deployment.deployment.invoke_url, "/^https?://([^/]*).*/", "$1")
origin_id = "apigw" # ID
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
Frontend cache behavior
The default_cache_behavior
is the path mapping to /
, which in our case is the frontend bucket. The target_origin_id
maps this to the
S3 origin.
default_cache_behavior {
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3" # Use the s3 origin
# Don't forward query string or cookies
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
viewer_protocol_policy = "redirect-to-https"
}
API cache behavior
And finally, map the API origin to the path /api/*
.
ordered_cache_behavior {
path_pattern = "/api/*"
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "apigw" # Use the apigw origin
# Disable caching
default_ttl = 0
min_ttl = 0
max_ttl = 0
# Forward everything
forwarded_values {
query_string = true
cookies {
forward = "all"
}
}
viewer_protocol_policy = "redirect-to-https"
}
CloudFront domain name
The serverless app with the frontend and the API wired together is now available under the CloudFront distribution's domain:
output "frontend_url" {
value = aws_cloudfront_distribution.distribution.domain_name
}
Conclusion
This is a rare case when using one more service reduces complexity. Cross-origin requests require some careful planning and this is a class of problems that is better to be just avoided altogether. By using one domain only you not only simplify backend configurations but also make sure no attack surface regarding cross-origin configuration opens up.
Which is a good thought to have during the 20-odd minutes of waiting for the CloudFront distribution to deploy.