25.6 Origin Access Control (OAC): Securing S3 Origins
Right, let’s talk about locking down your S3 bucket when CloudFront is your front door. This is one of those things AWS makes sound complicated, but the core idea is beautifully simple: your S3 bucket should be a hermit that only accepts calls from its one trusted friend, CloudFront. Everyone else—including users with their own AWS credentials—gets the door slammed in their face. We used to do this with an Origin Access Identity (OAI), which was basically a special IAM user. Now, we use the newer, shinier, and frankly more secure Origin Access Control (OAC). OAC uses IAM roles, which is the modern, preferred way for services to talk to each other in AWS. Consider this an upgrade.
Why You Absolutely Need This
Without OAC (or its predecessor, OAI), you have two spectacularly bad options. First, you could leave your S3 bucket public. Please don’t. This is how “data breach” headlines are written. The second option is to let users access the bucket using their own IAM credentials, which is a logistical nightmare for serving public website assets. OAC elegantly solves this by having CloudFront impersonate a privileged user via a role. Every request CloudFront makes to S3 is signed with its special permissions, and your bucket policy is written to only accept requests signed with that specific role’s credentials. It’s a bouncer checking for a specific VIP pass.
The Two-Part Dance: Trust and Policy
Setting this up is a two-step process, and if you mess up either one, it fails spectacularly with a infuriatingly generic 403 Forbidden error. You’ve been warned.
Step 1 is creating the OAC itself in the CloudFront distribution. This creates the IAM role behind the scenes. You tell CloudFront, “Hey, I want to use an OAC for this S3 origin.” AWS handles the role creation for you.
Step 2 is writing the bucket policy on the S3 bucket. This is where you, the developer, actually write the security rules. This policy must explicitly grant the specific OAC role permission to perform the actions it needs (s3:GetObject). The policy uses the role’s unique Amazon Resource Name (ARN) to identify it.
Here’s the magic. When you create the OAC in the CloudFront console, it offers to generate the bucket policy for you. Use this feature. It saves you from the most common pitfall: typos in the role ARN. Let’s look at what it creates.
The Bucket Policy, Demystified
If you let CloudFront generate it, you’ll get a policy that looks something like this. Let’s break down why it works.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::your-secure-bucket-name/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/EYEXAMPLE123"
}
}
}
]
}
Notice the Condition block. This is the critical part. The Principal is broadly “the CloudFront service,” which is too vague. The Condition tightens it down: “I will only allow this GetObject request if it also comes from my specific CloudFront distribution (EYEXAMPLE123).” This prevents other distributions in your account (or worse, someone else’s account if they guess your bucket name) from accessing the bucket. It’s defense in depth.
The Programmatic Way (Terraform Example)
Of course, we’re not clicking around in the console all day. Here’s how you’d define this relationship in Terraform, which makes the dependency chain crystal clear.
# Create the S3 bucket
resource "aws_s3_bucket" "my_secure_bucket" {
bucket = "my-unique-secure-bucket-name"
}
# Create the CloudFront distribution with an OAC
resource "aws_cloudfront_distribution" "my_distribution" {
origin {
domain_name = aws_s3_bucket.my_secure_bucket.bucket_regional_domain_name
origin_id = "myS3Origin"
origin_access_control_id = aws_cloudfront_origin_access_control.my_oac.id
}
# ... (other required CF distribution settings) ...
}
# Create the OAC itself
resource "aws_cloudfront_origin_access_control" "my_oac" {
name = "My S3 OAC"
description = "Access control for the S3 origin"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
# Create the bucket policy that ONLY allows our CloudFront distribution
resource "aws_s3_bucket_policy" "allow_cloudfront_only" {
bucket = aws_s3_bucket.my_secure_bucket.id
policy = data.aws_iam_policy_document.allow_cloudfront_only.json
}
data "aws_iam_policy_document" "allow_cloudfront_only" {
statement {
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.my_secure_bucket.arn}/*"]
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.my_distribution.arn]
}
}
}
The beauty of this code is that it explicitly links everything together. The bucket policy references the distribution ARN, which forces Terraform to create the distribution first, ensuring the ARN exists. It’s a clean, bulletproof setup.
Common Facepalms and Gotchas
- The Silent 403: You update the distribution but forget to update the bucket policy. Or you typo the distribution ARN in the policy. CloudFront tries to fetch the object, S3 denies it, and CloudFront just hands you a 403. The error logging is, unhelpfully, in the CloudFront reports, not in S3. Check your policy first.
- The OAC is Not for Custom Headers: If you’re using a custom origin (like an EC2 instance) and authenticating with headers, you don’t need an OAC. That’s for S3. Don’t get confused.
- Caching and Invalidation: Remember, securing the origin doesn’t change how caching works. If you need to force CloudFront to fetch a new version of a private object, you still have to create an invalidation. The security only governs the fetch, not the cache.
Get this setup right, and you can sleep soundly knowing your assets are served with the performance of a CDN but the security of a vault.