Skip to main content

Serving files

Uploading is half the job. This page covers how files get back to users: through a CDN for public content, and through short-lived signed URLs for private content. It also covers CORS and caching, which break in ways that only show in a browser.

How it works​

  • The bucket is always private. "Block all public access" stays on.
  • Public content (app images, published covers) goes through CloudFront. CloudFront reads the bucket using Origin Access Control (OAC). OAC means CloudFront signs its own requests to S3, and the bucket policy trusts only that one distribution.
  • Private content (profile photos, documents, member videos) is served with a signed GET URL that the API makes when someone reads the record. The URL expires in minutes.
  • The database stores the key. The URL is made fresh on every read.

Which way to serve​

ContentHowWhy
App icons, catalog images, published assetsCloudFront public path, long cacheFast, cached at the edge, no signing cost
Versioned bundles (translation files, data packs)CloudFront, file name carries the version, cached for a yearThe name changes when the content changes
Profile photos, ID documents, CVsPresigned S3 GET, minutesOnly allowed viewers ever get a URL
Member videosPresigned S3 GET with ResponseContentType, supports RangePlayers need Range requests, S3 supports them
Private content for many files at once, at scaleCloudFront signed URLs or signed cookiesEdge caching plus access control

CloudFront signed URLs and cookies​

CloudFront can also check a signature, using a key pair you register in a trusted key group.

  • Signed URL: one signature per file. Good for a single download.
  • Signed cookie: one cookie covers many files under a path. Good for a video made of many segment files (HLS), where signing every segment URL is painful.

Both keep the bucket private and still get edge caching. Use them when private content is read often enough that caching matters. For a few reads per file, a presigned S3 GET is simpler.

How to build it​

Presigned GET at read time​

import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({});
const KEY_RE = /^user-uploads\/photos\/[0-9a-f-]{36}\/[0-9a-f-]{36}\.(jpg|png)$/;

export async function signPhoto(key: string, ttlSeconds = 600) {
// Only sign keys this feature mints. Never sign an arbitrary key from input.
if (!KEY_RE.test(key)) throw new Error("invalid_key");
return getSignedUrl(
s3,
new GetObjectCommand({
Bucket: "example-uploads",
Key: key,
ResponseContentType: "image/jpeg", // S3 sends this Content-Type back
ResponseContentDisposition: "inline", // or "attachment" to force a download
}),
{ expiresIn: ttlSeconds },
);
}
  • Sign in the read path, after the access check. The URL is the permission.
  • Do not sign URLs for every item in a long list. For a list page, return a cheap flag such as hasVideo, and sign the one URL when the user opens the item.

OAC: bucket policy and KMS key policy​

An OAC distribution 403s on every key until both of these exist.

data "aws_iam_policy_document" "assets_bucket" {
statement {
sid = "AllowCloudFrontRead"
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.assets.arn}/public/*"]

principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}

condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.assets.arn]
}
}
}

resource "aws_s3_bucket_policy" "assets" {
bucket = aws_s3_bucket.assets.id
policy = data.aws_iam_policy_document.assets_bucket.json
}

If the bucket uses SSE-KMS with your own key, the KMS key policy also needs a statement that lets cloudfront.amazonaws.com call kms:Decrypt, with the same AWS:SourceArn condition. Without it, CloudFront gets the object but cannot decrypt it.

CORS on the bucket and on CloudFront​

A browser only reads a cross-origin response if the server sends Access-Control-Allow-Origin. Uploads also send a preflight (OPTIONS) request first. Which server must send the header depends on the URL the client uses.

Bucket CORS. Reads open to any origin (the signed URL is already the permission), writes only from our own web apps:

resource "aws_s3_bucket_cors_configuration" "assets" {
bucket = aws_s3_bucket.assets.id

cors_rule {
allowed_methods = ["GET", "HEAD"]
allowed_origins = ["*"]
allowed_headers = ["*"]
expose_headers = ["Content-Length", "Content-Range", "Accept-Ranges"]
max_age_seconds = 3600
}

cors_rule {
allowed_methods = ["PUT", "POST", "DELETE"]
allowed_origins = ["https://app.example.com", "https://admin.example.com"]
allowed_headers = ["*"]
expose_headers = ["ETag"]
max_age_seconds = 3600
}
}

On CloudFront, attach a response headers policy with a CORS config. Bucket CORS alone is not enough when the distribution does not forward the Origin header to S3.

Test CORS with a preflight, not a page load:

curl -i -X OPTIONS "https://example-uploads.s3.amazonaws.com/user-uploads/test.jpg" \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: content-type"
# Look for: Access-Control-Allow-Origin: https://app.example.com

Cache headers​

  • Content that never changes at its key: set Cache-Control: public, max-age=31536000, immutable. Put a version or a hash in the file name.
  • A small pointer file (a manifest that says which version is live): short max-age, such as 60 seconds.
  • Publish order: upload the new versioned file first, the manifest last. The manifest flip is the go-live moment.
  • Never overwrite a file at the same key if it was served with a long cache. Edges keep the old copy. If you must, run a CloudFront invalidation for that path.
  • If you cannot rename (a stable key that gets replaced), add a version query parameter to the public CDN URL, like ?v=12. Never add query parameters to a presigned URL. That breaks the signature.

Set Cache-Control and Content-Type on the object when you upload it. S3 sends them back on every GET, and CloudFront honours them.

Why presigned URLs must not go in the database​

ProblemWhat happens
They expireA stored URL works for minutes, then 403s forever.
Credentials rotateURLs signed by role credentials die when the credentials expire, often within hours.
They are bearer tokensAnyone who reads the table or a log can open the file.
Hosting changesA move from presigned S3 to CloudFront, or a new CDN host, would need a data migration.

Store the key. Build the URL in the read path. This also lets you change the serving method in one place.

Rules​

Rule

The bucket stays private. Public content goes out through CloudFront with OAC. Never turn off "Block public access" to make an image load.

Rule

Store keys, never URLs. Sign at read time, after the access check.

Rule

Grant the API role read on the whole bucket (or the whole user-uploads area) and scope writes by prefix. Never list read prefixes one by one. A missing read prefix fails silently: the API happily returns a signed URL that 403s.

Rule

Bucket CORS lives in Terraform for every environment, with that environment's origins. Check it with an OPTIONS preflight after every apply and every hostname change.

tip

Signing a URL does not check that the object exists or that you may read it. A missing object or a missing permission shows up as a broken image in the client, with HTTP 200 from the API and nothing in the logs. When content "loads but shows no pictures", check the object and the role's S3 grant first.

Mistakes we made​

  • The production bucket had no CORS rules at all. Dev had them in Terraform. Staging had them added by hand. Prod had none, so every browser upload died with "No Access-Control-Allow-Origin header". curl and server tests passed, because CORS only exists in browsers. A bucket with no CORS config is not open by default. It fails every preflight.
  • CORS lists went stale after a hostname rename. Dev and staging still listed the old web hostname, so uploads from the new hostname failed. One staging list even contained the production origin. Someone had fixed the wrong bucket.
  • Prod served every asset as a presigned S3 GET. The code chose "CDN or presigned" by checking whether the bucket name contained a marker string. The marker in prod matched no bucket, so nothing used the CDN. The CloudFront CORS policy then did nothing, because browsers never talked to CloudFront. Make "public or private" an explicit setting, not a name match.
  • Images went blank with no error. The prod role's read grant listed six prefixes and missed seven others. Presigning still worked, so the API returned 200 and the images 403'd. Fixed by granting read on the whole bucket. IAM is checked per request, so no redeploy was needed.
  • The CDN 403'd for every key. The distribution had an OAC but no bucket policy for CloudFront and no KMS decrypt grant. Nobody noticed because everything used presigned URLs anyway.
  • Rows pointed at objects that did not exist. Their presigned URLs 404'd. The database and the bucket had drifted apart. When you seed or copy data, copy the objects too and check each key.
  • An overwritten file kept its old copy at the edge. Bundles were cached for a year, then re-uploaded at the same name. Edges served the old one. Use a new file name per version, or invalidate.

Checklist​

  • Bucket has "Block all public access" on.
  • Public content: CloudFront with OAC, a bucket policy with AWS:SourceArn, and KMS decrypt if SSE-KMS.
  • Private content: signed at read time, after the access check, short TTL.
  • The database stores keys only.
  • Signers only sign keys that match the feature's key pattern.
  • Role: read on the whole bucket, writes per prefix, same in every environment.
  • Bucket CORS in Terraform for every environment, tested with an OPTIONS preflight.
  • CloudFront has a CORS response headers policy if browsers read through it.
  • Long-cached files have versioned names. Pointer files have a short max-age.
  • Content-Type and Cache-Control are set at upload time.