Skip to content
ExponaBLOG
AWS FOR DEVELOPERS · DAY 034 / 100

S3 security essentials: a practical guide for developers

Most cloud data leaks are misconfigured buckets.

Expona Admin7 min read

I've seen too many production incidents caused by a single misconfigured S3 bucket. Most cloud data leaks aren't the result of sophisticated attacks—they're the result of overlooked security settings that leave sensitive data exposed to the entire internet. Today, we're covering the core practices that prevent those incidents: S3 security essentials that every developer should know before pushing to production.

Understanding the S3 Security Landscape

S3 is deceptively simple from the outside, but its permission model has many layers. By default, all buckets and objects are private, which is good. But the moment you add a policy, enable website hosting, or generate a public URL without thinking, you've created a vector for exposure. The security essentials we're discussing today address the most common gaps I've encountered across e-commerce platforms, SaaS applications, and data warehouses.

The challenge isn't complexity—it's that security in S3 is opt-in, not opt-out. You have to actively lock things down, and when you miss even one setting, the blast radius can be your entire dataset.

Block Public Access — Enable It at the Account Level

This is your first line of defense. AWS provides a Block Public Access feature that sits at both the bucket level and the account level. The account-level setting is non-negotiable in production environments.

When you enable Block Public Access at the account level, it overrides any bucket policy or ACL that might accidentally (or intentionally, in a moment of debugging) grant public access. Think of it as a circuit breaker: even if someone misconfigures a bucket policy, AWS will reject the public access grant before it takes effect.

Here's what enabling it looks like in practice. Via the AWS CLI:

bash
aws s3control put-public-access-block \
  --account-id 123456789012 \
  --public-access-block-configuration \
  "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

Breaking down the four settings:

  • BlockPublicAcls: Prevents new public ACL assignments.
  • IgnorePublicAcls: Makes existing public ACLs ineffective.
  • BlockPublicPolicy: Prevents bucket policies that grant public access.
  • RestrictPublicBuckets: Makes public buckets act as if they were private for unauthenticated users.

In a real scenario: you're building a CRM platform and have a bucket for customer data exports. A junior developer, trying to debug a download issue, adds a bucket policy to make it public. With account-level Block Public Access enabled, that policy is silently ignored, and the customer data stays protected. Without it, your customer data is now publicly accessible.

Bucket Policies — Deny Non-HTTPS Requests and Restrict Principals

Once you've set the circuit breaker, bucket policies let you define granular access rules. The two most critical practices are enforcing HTTPS and restricting which principals can access your data.

Here's a real-world example. On a ride-hailing platform, we stored driver documents in S3. The bucket policy had to ensure:

  1. Only HTTPS requests were allowed (no unencrypted data in transit).
  2. Only authenticated users with the correct IAM role could access it.
  3. All other access was explicitly denied.

Here's the bucket policy we used:

bash
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::driver-documents",
        "arn:aws:s3:::driver-documents/*"
      ],
      "Condition": {
        "Bool": {
          "aws:SecureTransport": "false"
        }
      }
    },
    {
      "Sid": "AllowAuthenticatedAccess",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/DocumentProcessorRole"
      },
      "Action": [
        "s3:GetObject",
        "s3:PutObject"
      ],
      "Resource": "arn:aws:s3:::driver-documents/*"
    }
  ]
}

The first statement denies all non-HTTPS requests, regardless of who's making them. The second statement allows only a specific IAM role to read and write objects. Any other principal—including root account users—cannot bypass these rules.

The key principle: always use explicit denial for risky conditions (like unencrypted transport) and explicit allowance for specific, needed principals. Don't rely on the absence of an Allow statement to keep things secure.

Encryption — Default Encryption with SSE-S3 or SSE-KMS

Encryption at rest is table stakes. S3 offers two main server-side encryption options: SSE-S3 (AWS-managed keys) and SSE-KMS (customer-managed keys via AWS KMS).

On a travel booking platform, we needed to store payment tokens and personal identification data. Regulatory requirements meant we had to prove encryption, and audit trails mattered. SSE-KMS was the answer because it gives you control over key rotation, usage auditing, and access policies.

Enable default encryption on your bucket:

bash
aws s3api put-bucket-encryption \
  --bucket travel-booking-data \
  --server-side-encryption-configuration '{
    "Rules": [
      {
        "ApplyServerSideEncryptionByDefault": {
          "SSEAlgorithm": "aws:kms",
          "KMSMasterKeyID": "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"
        }
      }
    ]
  }'

When default encryption is enabled, every object uploaded to the bucket is automatically encrypted with your chosen algorithm, even if the client doesn't explicitly request it. This eliminates the risk of someone accidentally uploading unencrypted sensitive data.

Choose SSE-S3 for simplicity and zero key management overhead. Choose SSE-KMS when you need audit trails, granular permissions on key usage, or regulatory compliance requirements.

Serve via CloudFront — Private Buckets with Origin Access Control

The most common pattern in modern applications is to keep your S3 bucket completely private and serve content through CloudFront with Origin Access Control (OAC). This decouples your bucket from the public internet entirely.

On an e-commerce platform, product images were stored in S3, but serving them directly created two problems: the bucket was technically accessible if someone guessed the URL format, and we had no caching layer for high-traffic images. CloudFront with OAC solved both.

Here's the pattern:

  1. S3 bucket is completely private (no public access).
  2. CloudFront distribution points to the bucket as its origin.
  3. Origin Access Control ensures CloudFront can read from the bucket, but the public cannot.
  4. CloudFront handles caching and edge delivery.
  5. Clients access images via the CloudFront distribution URL, not the S3 URL.

The CloudFront distribution automatically creates a bucket policy that grants itself access. Your bucket stays private, but your content is still accessible and cached globally. This is the gold standard for serving user-facing content from S3.

When creating the distribution in Terraform (common infrastructure-as-code approach):

bash
resource "aws_s3_bucket" "product_images" {
  bucket = "product-images-private"
}

resource "aws_s3_bucket_public_access_block" "product_images" {
  bucket = aws_s3_bucket.product_images.id

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

resource "aws_cloudfront_distribution" "product_images" {
  origin {
    domain_name              = aws_s3_bucket.product_images.bucket_regional_domain_name
    origin_access_control_id = aws_cloudfront_origin_access_control.oac.id
    origin_id                = "s3-origin"
  }

  enabled = true

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "s3-origin"

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    viewer_protocol_policy = "redirect-to-https"
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

Common Mistakes

Mistake 1: Relying on obscurity. Some developers assume that because the bucket name is long and random, no one will find it. Search engines index S3 URLs, and attackers actively scan for misconfigured buckets. Obscurity is not security.

Mistake 2: Forgetting bucket versioning and lifecycle policies. S3 security is not just about access control. Enable versioning to protect against accidental or malicious deletion, and set lifecycle policies to delete old versions after a retention period. A compromised account with delete permissions can still destroy your data if versioning isn't enabled.

Mistake 3: Not testing bucket policies. A policy looks correct in JSON but fails in practice because of a typo or incorrect resource ARN. Test every policy change in a non-production bucket first. Use the AWS Policy Simulator to validate before deployment.

Mistake 4: Mixing public and private content in one bucket. If you have both public product images and private customer data in the same bucket, it's easy to accidentally grant the wrong policy. Use separate buckets for different sensitivity levels.

Mistake 5: Ignoring CloudTrail logs. Enable CloudTrail to log all S3 API calls. If your bucket is compromised, you'll want to know exactly what happened and when. Most data leaks are discovered weeks or months later; CloudTrail is your forensic record.

Key Takeaways

  • Enable Block Public Access at the account level in every production environment. This is your safety net against misconfigured bucket policies.
  • Write explicit bucket policies that deny non-HTTPS requests and restrict principals to only those that need access.
  • Enable default encryption with SSE-S3 for simple use cases or SSE-KMS for compliance-heavy applications.
  • Serve user-facing content through CloudFront with Origin Access Control, keeping your bucket completely private.
  • Test bucket policies before deployment, enable CloudTrail logging, and use separate buckets for different data sensitivity levels.
  • Remember that S3 security is layered: Block Public Access + bucket policies + encryption + CloudFront = defense in depth.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles