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
| Content | How | Why |
|---|---|---|
| App icons, catalog images, published assets | CloudFront public path, long cache | Fast, cached at the edge, no signing cost |
| Versioned bundles (translation files, data packs) | CloudFront, file name carries the version, cached for a year | The name changes when the content changes |
| Profile photos, ID documents, CVs | Presigned S3 GET, minutes | Only allowed viewers ever get a URL |
| Member videos | Presigned S3 GET with ResponseContentType, supports Range | Players need Range requests, S3 supports them |
| Private content for many files at once, at scale | CloudFront signed URLs or signed cookies | Edge 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
| Problem | What happens |
|---|---|
| They expire | A stored URL works for minutes, then 403s forever. |
| Credentials rotate | URLs signed by role credentials die when the credentials expire, often within hours. |
| They are bearer tokens | Anyone who reads the table or a log can open the file. |
| Hosting changes | A 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
The bucket stays private. Public content goes out through CloudFront with OAC. Never turn off "Block public access" to make an image load.
Store keys, never URLs. Sign at read time, after the access check.
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.
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.
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
OPTIONSpreflight. - 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-TypeandCache-Controlare set at upload time.