Skip to main content

AWS Security

The Complete Security Checklist for S3-Backed Static Websites

S3 static websites are simple to deploy but easy to misconfigure. This comprehensive security checklist covers bucket policies, CloudFront integration, encryption, and access controls to keep your static site secure.

Cloud Associates

Cloud Associates

S3-backed static websites are everywhere. They’re cheap, scalable, and dead simple to deploy. But “simple” doesn’t mean “secure by default.”

Every month we audit startups who’ve deployed static sites to S3 and find the same security issues: publicly accessible buckets that should be private, missing encryption, no access logging, and CloudFront distributions that anyone can bypass by hitting S3 directly.

The worst part? Most of these issues are obvious in hindsight and take minutes to fix—if you know what to look for.

This guide is the complete security checklist we use when auditing S3-backed static websites. Follow it, and you’ll have a properly secured static site that follows AWS best practices.

The Core Security Principle

Your S3 bucket should NEVER be publicly accessible. Ever.

Enforce S3 Public Access Block Settings with Service Control Policies.

Even for static websites, you should serve content through CloudFront with Origin Access Control (OAC), not by making your S3 bucket public.

Why? Because:

  1. Public buckets are the #1 source of AWS data breaches
  2. You can’t control access or add WAF protection to public S3
  3. You lose caching benefits and performance optimisations
  4. You can’t enforce HTTPS or control headers
  5. You have no DDoS protection

Let’s build a properly secured static site from the ground up.

The Security Checklist

1. Block All Public Access

What to do: Enable “Block all public access” at both the bucket level AND the account level.

How to check:

aws s3api get-public-access-block --bucket your-bucket-name

Expected output:

{
  "PublicAccessBlockConfiguration": {
    "BlockPublicAcls": true,
    "IgnorePublicAcls": true,
    "BlockPublicPolicy": true,
    "RestrictPublicBuckets": true
  }
}

How to fix (if not enabled):

aws s3api put-public-access-block \
  --bucket your-bucket-name \
  --public-access-block-configuration \
  "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

Why this matters: This single setting prevents accidental public exposure of your bucket. Even if someone applies a public bucket policy or ACL, AWS will block it.

Common mistake: Disabling this because “the static website feature requires public access.” This is outdated advice from before CloudFront OAC existed.

2. Enable Server-Side Encryption

What to do: Enable default encryption for all objects in your bucket.

How to check:

aws s3api get-bucket-encryption --bucket your-bucket-name

How to configure (SSE-S3 - free):

aws s3api put-bucket-encryption \
  --bucket your-bucket-name \
  --server-side-encryption-configuration '{
    "Rules": [
      {
        "ApplyServerSideEncryptionByDefault": {
          "SSEAlgorithm": "AES256"
        },
        "BucketKeyEnabled": true
      }
    ]
  }'

Alternative (SSE-KMS - for compliance requirements):

aws s3api put-bucket-encryption \
  --bucket your-bucket-name \
  --server-side-encryption-configuration '{
    "Rules": [
      {
        "ApplyServerSideEncryptionByDefault": {
          "SSEAlgorithm": "aws:kms",
          "KMSMasterKeyID": "your-kms-key-id"
        },
        "BucketKeyEnabled": true
      }
    ]
  }'

Why this matters: Encrypts data at rest. While S3 data is already protected by AWS infrastructure security, encryption adds defense-in-depth and is required for many compliance standards.

Cost impact: SSE-S3 is free. SSE-KMS costs $0.03 per 10,000 requests (usually negligible for static sites).

3. Enable Versioning

What to do: Turn on S3 versioning to protect against accidental deletions and overwrites.

How to check:

aws s3api get-bucket-versioning --bucket your-bucket-name

How to enable:

aws s3api put-bucket-versioning \
  --bucket your-bucket-name \
  --versioning-configuration Status=Enabled

Why this matters:

  • Protects against accidental file deletion
  • Allows rollback to previous versions
  • Helps with incident response if files are maliciously modified

Cost impact: You pay for all versions stored. Mitigate with lifecycle policies to delete old versions after 30-90 days:

{
  "Rules": [
    {
      "Id": "DeleteOldVersions",
      "Status": "Enabled",
      "NoncurrentVersionExpiration": {
        "NoncurrentDays": 90
      }
    }
  ]
}

4. Configure CloudFront with Origin Access Control (OAC)

What to do: Use CloudFront as the only way to access your S3 bucket, and secure it with OAC.

Step 1: Create Origin Access Control in CloudFront

aws cloudfront create-origin-access-control \
  --origin-access-control-config \
  Name=static-site-oac,\
  SigningProtocol=sigv4,\
  SigningBehavior=always,\
  OriginAccessControlOriginType=s3

Step 2: Update CloudFront distribution to use OAC Update your distribution’s origin configuration:

{
  "Id": "S3-your-bucket",
  "DomainName": "your-bucket.s3.us-east-1.amazonaws.com",
  "OriginAccessControlId": "your-oac-id",
  "S3OriginConfig": {
    "OriginAccessIdentity": ""
  }
}

Step 3: Update S3 bucket policy to allow only CloudFront

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipal",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::your-bucket-name/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::YOUR-ACCOUNT-ID:distribution/YOUR-DISTRIBUTION-ID"
        }
      }
    }
  ]
}

Why this matters:

  • S3 bucket remains private
  • Only CloudFront can access objects
  • Prevents direct S3 access bypass
  • Enables WAF, DDoS protection, and caching

Common mistake: Using the old Origin Access Identity (OAI) instead of OAC. OAC is newer, more secure, and supports all AWS regions.

5. Enforce HTTPS Only

What to do: Configure CloudFront to redirect HTTP to HTTPS and enforce TLS 1.2+.

CloudFront distribution settings:

{
  "ViewerProtocolPolicy": "redirect-to-https",
  "MinimumProtocolVersion": "TLSv1.2_2021"
}

Also disable insecure ciphers:

{
  "CustomSSLSupportMethod": "sni-only",
  "MinimumProtocolVersion": "TLSv1.2_2021",
  "SslSupportMethod": "sni-only"
}

Why this matters: Prevents man-in-the-middle attacks, ensures data in transit is encrypted, and is required for modern browsers.

Free SSL certificate: Use AWS Certificate Manager (ACM) to get a free SSL certificate for your domain.

6. Enable Access Logging

What to do: Log all S3 access requests and CloudFront requests.

S3 access logging:

aws s3api put-bucket-logging \
  --bucket your-bucket-name \
  --bucket-logging-status '{
    "LoggingEnabled": {
      "TargetBucket": "your-logs-bucket",
      "TargetPrefix": "s3-access-logs/"
    }
  }'

CloudFront access logging: Enable in your CloudFront distribution configuration:

{
  "Logging": {
    "Enabled": true,
    "IncludeCookies": false,
    "Bucket": "your-logs-bucket.s3.amazonaws.com",
    "Prefix": "cloudfront-logs/"
  }
}

Why this matters:

  • Audit trail for security incidents
  • Detect unusual access patterns
  • Required for compliance (SOC 2, ISO 27001, etc.)
  • Helps with debugging and analytics

Cost impact: Minimal. Logs go to S3 (~$0.023/GB storage). Use lifecycle policies to delete old logs after 90 days.

Best practice: Create a separate bucket for logs with restricted access.

7. Set Proper Security Headers

What to do: Configure CloudFront to add security headers to all responses.

CloudFront Response Headers Policy:

{
  "SecurityHeadersConfig": {
    "StrictTransportSecurity": {
      "Override": true,
      "AccessControlMaxAgeSec": 31536000,
      "IncludeSubdomains": true,
      "Preload": true
    },
    "ContentTypeOptions": {
      "Override": true
    },
    "FrameOptions": {
      "Override": true,
      "FrameOption": "DENY"
    },
    "XSSProtection": {
      "Override": true,
      "Protection": true,
      "ModeBlock": true
    },
    "ReferrerPolicy": {
      "Override": true,
      "ReferrerPolicy": "strict-origin-when-cross-origin"
    },
    "ContentSecurityPolicy": {
      "Override": true,
      "ContentSecurityPolicy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;"
    }
  }
}

Why each header matters:

  • HSTS: Forces HTTPS, prevents downgrade attacks
  • X-Content-Type-Options: Prevents MIME-sniffing attacks
  • X-Frame-Options: Prevents clickjacking
  • X-XSS-Protection: Enables browser XSS filters
  • Referrer-Policy: Controls referrer information leakage
  • Content-Security-Policy: Prevents XSS and injection attacks

How to test: Use https://securityheaders.com to scan your site.

Target grade: A or A+ on Security Headers scan.

8. Implement Bucket Policies for Least Privilege

What to do: Grant only the minimum necessary permissions for your CI/CD pipeline and team.

Example: CI/CD deployment policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCICD",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::ACCOUNT-ID:role/github-actions-deployment"
      },
      "Action": [
        "s3:PutObject",
        "s3:PutObjectAcl",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::your-bucket-name/*"
    },
    {
      "Sid": "AllowListBucket",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::ACCOUNT-ID:role/github-actions-deployment"
      },
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::your-bucket-name"
    }
  ]
}

Why this matters: If your CI/CD credentials are compromised, attackers can only upload/delete files, not change bucket configuration or access other resources.

Best practice: Use IAM roles for CI/CD, not long-lived access keys.

9. Enable MFA Delete

What to do: Require multi-factor authentication to delete objects or disable versioning.

How to enable (must use root account):

aws s3api put-bucket-versioning \
  --bucket your-bucket-name \
  --versioning-configuration Status=Enabled,MFADelete=Enabled \
  --mfa "arn:aws:iam::ACCOUNT-ID:mfa/root-account-mfa-device XXXXXX"

Why this matters: Prevents accidental or malicious deletion of your entire site, even if AWS credentials are compromised.

When to use: Essential for production sites, especially if you have multiple team members with AWS access.

Trade-off: Makes emergency deletions harder (you need MFA device). Consider this for prod-only.

10. Configure Cache-Control Headers

What to do: Set appropriate Cache-Control headers to balance performance and update speed.

Static assets (versioned files):

# For files like app.abc123.js
Cache-Control: public, max-age=31536000, immutable

Upload via AWS CLI:

aws s3 cp dist/ s3://your-bucket/ \
  --recursive \
  --cache-control "public, max-age=31536000, immutable" \
  --exclude "*.html"

HTML files (never cache):

# For index.html and other HTML files
Cache-Control: public, max-age=0, must-revalidate

Upload via AWS CLI:

aws s3 cp dist/ s3://your-bucket/ \
  --recursive \
  --cache-control "public, max-age=0, must-revalidate" \
  --include "*.html"

Why this matters:

  • Improves performance (fewer origin fetches)
  • Reduces S3 request costs
  • Ensures users get updates quickly for HTML
  • Allows long caching for versioned assets

CloudFront advantage: CloudFront respects these headers and caches accordingly.

11. Implement WAF Rules

What to do: Add AWS WAF to your CloudFront distribution with core security rules.

Minimum recommended rules:

  1. AWS Managed Rules - Core Rule Set (OWASP Top 10 protection)
  2. AWS Managed Rules - IP Reputation List (block known bad actors)
  3. Rate limiting (prevent abuse)

Example WAF configuration:

{
  "Name": "static-site-waf",
  "Rules": [
    {
      "Name": "AWS-AWSManagedRulesCommonRuleSet",
      "Priority": 1,
      "Statement": {
        "ManagedRuleGroupStatement": {
          "VendorName": "AWS",
          "Name": "AWSManagedRulesCommonRuleSet"
        }
      },
      "OverrideAction": { "None": {} }
    },
    {
      "Name": "AWS-AWSManagedRulesAmazonIpReputationList",
      "Priority": 0,
      "Statement": {
        "ManagedRuleGroupStatement": {
          "VendorName": "AWS",
          "Name": "AWSManagedRulesAmazonIpReputationList"
        }
      },
      "OverrideAction": { "None": {} }
    },
    {
      "Name": "RateLimitRule",
      "Priority": 10,
      "Statement": {
        "RateBasedStatement": {
          "Limit": 2000,
          "AggregateKeyType": "IP"
        }
      },
      "Action": { "Block": {} }
    }
  ]
}

Cost: $5/month for web ACL + $1 per million requests. For most static sites: $5-8/month.

Why this matters: Even static sites can be targets for DDoS, scraping, and automated attacks. WAF blocks malicious traffic before it costs you money.

12. Enable CloudWatch Alarms

What to do: Set up monitoring for unusual activity.

Recommended alarms:

1. Spike in 4xx errors (possible attack or misconfiguration):

aws cloudwatch put-metric-alarm \
  --alarm-name static-site-high-4xx-errors \
  --alarm-description "Alert on high 4xx error rate" \
  --metric-name 4xxErrorRate \
  --namespace AWS/CloudFront \
  --statistic Average \
  --period 300 \
  --threshold 5 \
  --comparison-operator GreaterThanThreshold \
  --evaluation-periods 2

2. Unusual traffic spike (possible DDoS or viral content):

aws cloudwatch put-metric-alarm \
  --alarm-name static-site-traffic-spike \
  --alarm-description "Alert on unusual traffic" \
  --metric-name Requests \
  --namespace AWS/CloudFront \
  --statistic Sum \
  --period 300 \
  --threshold 100000 \
  --comparison-operator GreaterThanThreshold \
  --evaluation-periods 1

3. Cache hit ratio drop (possible configuration issue):

aws cloudwatch put-metric-alarm \
  --alarm-name static-site-low-cache-hit \
  --alarm-description "Alert on low cache hit ratio" \
  --metric-name CacheHitRate \
  --namespace AWS/CloudFront \
  --statistic Average \
  --period 300 \
  --threshold 85 \
  --comparison-operator LessThanThreshold \
  --evaluation-periods 2

Why this matters: Early detection of security incidents, misconfigurations, or performance problems.

The 5-Minute Security Audit

Use this quick checklist to audit an existing S3 static website:

# 1. Check if bucket is public (should be FALSE)
aws s3api get-bucket-acl --bucket your-bucket-name

# 2. Check public access block (should be all TRUE)
aws s3api get-public-access-block --bucket your-bucket-name

# 3. Check encryption (should be enabled)
aws s3api get-bucket-encryption --bucket your-bucket-name

# 4. Check versioning (should be Enabled)
aws s3api get-bucket-versioning --bucket your-bucket-name

# 5. Check logging (should be enabled)
aws s3api get-bucket-logging --bucket your-bucket-name

# 6. List bucket policy (should only allow CloudFront)
aws s3api get-bucket-policy --bucket your-bucket-name

# 7. Check CloudFront distribution security
aws cloudfront get-distribution --id YOUR-DISTRIBUTION-ID | jq '.Distribution.DistributionConfig.ViewerCertificate'

Grade your security:

  • All checks pass: A - Production ready
  • ⚠️ Missing 1-2 items: B - Needs improvement
  • ❌ Missing 3+ items: C - High risk, fix immediately

Infrastructure as Code: The Production-Ready Approach

Important: While we’ve provided AWS CLI commands throughout this guide for learning and quick testing, staging and production environments should always be managed via Infrastructure as Code (IaC).

Why IaC matters for security:

  • Repeatability: Consistent security configurations across environments
  • Version control: Track all changes to your infrastructure
  • Peer review: Security configurations can be reviewed before deployment
  • Audit trail: Complete history of who changed what and when
  • Disaster recovery: Rebuild entire infrastructure from code

Recommended IaC tools:

  • Terraform/OpenTofu - Most popular, cloud-agnostic
  • AWS CloudFormation - Native AWS solution, deep integration
  • AWS CDK - Define infrastructure using programming languages
  • Pulumi - Infrastructure as code using familiar languages

See the Terraform example below for a complete, production-ready implementation.

Common Vulnerabilities We Find

Vulnerability #1: Publicly Accessible Bucket

Symptom: Anyone can access https://your-bucket.s3.amazonaws.com/index.html directly.

Risk: High. Bypasses CloudFront caching, WAF, and access controls.

Fix: Enable “Block all public access” and restrict bucket policy to CloudFront OAC only.

Vulnerability #2: No Encryption at Rest

Symptom: get-bucket-encryption returns error or no encryption configured.

Risk: Medium. Data is still protected by AWS infrastructure, but fails compliance checks.

Fix: Enable SSE-S3 encryption (free) on the bucket.

Vulnerability #3: Missing Security Headers

Symptom: securityheaders.com gives you a D or F grade.

Risk: Medium. Site is vulnerable to XSS, clickjacking, and MITM attacks.

Fix: Add CloudFront Response Headers Policy with all recommended security headers.

Vulnerability #4: No Access Logging

Symptom: No access logs in S3 or CloudWatch.

Risk: Low for static sites, but High for compliance requirements.

Fix: Enable both S3 and CloudFront access logging to a separate logs bucket.

Vulnerability #5: Overly Permissive Bucket Policy

Symptom: Bucket policy allows Principal: "*" or overly broad permissions.

Risk: High. Anyone can access, modify, or delete content.

Fix: Restrict to specific IAM roles/users and CloudFront OAC only.

Terraform Example: Secure Static Site

Here’s a complete Terraform configuration for a secure S3 static website with CloudFront:

# S3 bucket (private)
resource "aws_s3_bucket" "static_site" {
  bucket = "my-secure-static-site"
}

# Block all public access
resource "aws_s3_bucket_public_access_block" "static_site" {
  bucket = aws_s3_bucket.static_site.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# Enable versioning
resource "aws_s3_bucket_versioning" "static_site" {
  bucket = aws_s3_bucket.static_site.id

  versioning_configuration {
    status = "Enabled"
  }
}

# Enable encryption
resource "aws_s3_bucket_server_side_encryption_configuration" "static_site" {
  bucket = aws_s3_bucket.static_site.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
    bucket_key_enabled = true
  }
}

# CloudFront Origin Access Control
resource "aws_cloudfront_origin_access_control" "static_site" {
  name                              = "static-site-oac"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

# CloudFront distribution
resource "aws_cloudfront_distribution" "static_site" {
  enabled             = true
  default_root_object = "index.html"

  origin {
    domain_name              = aws_s3_bucket.static_site.bucket_regional_domain_name
    origin_id                = "S3-${aws_s3_bucket.static_site.id}"
    origin_access_control_id = aws_cloudfront_origin_access_control.static_site.id
  }

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD", "OPTIONS"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = "S3-${aws_s3_bucket.static_site.id}"
    viewer_protocol_policy = "redirect-to-https"
    compress               = true

    forwarded_values {
      query_string = false
      cookies {
        forward = "none"
      }
    }
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
    minimum_protocol_version       = "TLSv1.2_2021"
  }
}

# Bucket policy - Allow CloudFront only
resource "aws_s3_bucket_policy" "static_site" {
  bucket = aws_s3_bucket.static_site.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowCloudFrontServicePrincipal"
        Effect = "Allow"
        Principal = {
          Service = "cloudfront.amazonaws.com"
        }
        Action   = "s3:GetObject"
        Resource = "${aws_s3_bucket.static_site.arn}/*"
        Condition = {
          StringEquals = {
            "AWS:SourceArn" = aws_cloudfront_distribution.static_site.arn
          }
        }
      }
    ]
  })
}

Conclusion

Securing an S3-backed static website isn’t complicated, but it requires attention to detail. The core principles:

  1. Never make your S3 bucket public - Use CloudFront with OAC
  2. Encrypt everything - Enable SSE-S3 encryption (it’s free)
  3. Enforce HTTPS - Redirect HTTP and use TLS 1.2+
  4. Add security headers - Use CloudFront Response Headers Policy
  5. Enable logging - For both S3 and CloudFront
  6. Implement WAF - At minimum, enable IP reputation and rate limiting
  7. Monitor for anomalies - Set up CloudWatch alarms

Time investment: 1-2 hours to implement all security controls for a new site.

Ongoing cost: ~$10-15/month (CloudFront + WAF) for most static sites.

Risk reduction: 95%+ of common S3 security vulnerabilities eliminated.

Need help securing your S3 static website or implementing a secure CloudFront distribution? Our CDN/WAF Services include complete S3 and CloudFront security hardening, WAF configuration, and ongoing monitoring delivered in weeks not months.