
Why Our Blog Images Broke Every Six Days

A six-day image rotation on a static CMS sounds like a trivial problem. It turned out to be a window into how S3 presigned URLs, CDN caching layers, and deployment pipelines interact in ways that documentation rarely makes explicit.
What Was Actually Happening
Our marketing site runs on a headless CMS with images stored in S3. The CMS generates presigned URLs when content editors upload assets. Those URLs get baked into the page at build time by a static site generator. The site deploys to a CDN.
The presigned URLs had a 7-day expiry. Deployments happened roughly every 24 hours. So far, so obvious.
The non-obvious part: the CDN had a default Cache-Control max-age of 604,800 seconds — exactly seven days. Edge nodes were caching the HTML that contained the presigned URLs. When the URL expired on S3's end, the CDN still served the old HTML. Visitors got 403s on every image.
Six days in practice because the first 24 hours of a presigned URL's life were spent in the build-and-deploy window, leaving six days before the CDN cache and the URL expiry collided.
Why Presigned URLs Are a Poor Fit for Static Build Pipelines
Presigned URLs are designed for short-lived, authenticated access to private objects. They work brilliantly when you're generating them at request time, server-side, for a specific user downloading a specific file. They work badly when you bake them into static HTML that gets cached independently.
The core mismatch:
| Concern | Presigned URL assumption | Static site assumption |
|---|---|---|
| URL lifetime | Minutes to hours | As long as the page is cached |
| Who generates it | Server at request time | Build process at deploy time |
| Cache layer | None (direct S3) | CDN with long TTLs |
| Re-generation trigger | Each request | Each deployment |
If your presigned URL TTL is shorter than your CDN cache TTL, images will break. That's the entire problem.
How Did We Fix It?
Two options, and the right one depends on what you're optimising for.
Option A: Make Objects Public and Use CloudFront Distributions
The simplest fix is to stop using presigned URLs for assets that are genuinely meant to be public. A blog image is not a sensitive file. Put it in a public-read S3 bucket (or restrict bucket access to a CloudFront OAI/OAC and serve it through a CloudFront distribution). The URL never expires. CDN cache invalidation is explicit, not accidental.
We did this for the marketing site. Total implementation time was about two hours, most of which was migrating existing objects and updating the CMS to write CloudFront URLs instead of presigned ones.
The trade-off: if you ever want to gate access to those images, you'll need to retrofit signed cookies or signed CloudFront URLs. Plan for that from the start if your content has any access-control requirements.
Option B: Regenerate Presigned URLs at Build Time With a Matching TTL Strategy
If the assets must stay private, align your TTLs deliberately. Set the presigned URL expiry to something significantly longer than your longest expected CDN cache window, and set Cache-Control: max-age on your HTML to something shorter than the URL expiry minus your deployment frequency.
Concretely: if you build daily and want a 30-day safety margin, set presigned URL TTL to 31 days and HTML max-age to 86,400 (one day). You'll need to rotate credentials and rebuild periodically, but the collision window disappears.
This is more operationally complex. If a deployment fails, you're extending the window. It also means your S3 credentials need a rotation policy that accounts for the URL TTL, not just the credential rotation schedule.
/// Not sure where to start?
Get the architecture before you commit
Tell us what you're building and we'll map the technical approach, stack, and rough timeline. No cost, no obligation, no sales call required.
What Does This Have to Do With Your CDN Configuration?
More than most people check. A few things worth auditing:
Varyheaders: if your CDN respectsVary: Accept-Encodingbut strips otherVaryheaders, you can end up with multiple versions of the same HTML cached at the edge with different embedded URLs.- Surrogate keys / cache tags: CloudFront doesn't support surrogate keys natively. If you're using Fastly or Varnish, tag your HTML responses with the asset keys so you can purge HTML when you upload a new image, not just the image itself.
stale-while-revalidate: this directive lets browsers serve stale content while fetching fresh. Fine for most resources, but it extends the effective lifetime of HTML containing presigned URLs beyond the explicitmax-age. Account for it.- S3 Transfer Acceleration: unrelated to this bug, but if you're already auditing your S3 setup, check whether Transfer Acceleration is enabled on a bucket that serves static assets. You're paying for it and getting nothing from it if CloudFront is in front.
Would a Different Architecture Have Avoided This?
Yes, several would have.
A server-side rendered application generates presigned URLs fresh on each request. The CDN caches at the API or page level with short TTLs or bypasses cache for authenticated content. The presigned URL expiry never outlives the cached response because there's no long-lived cached HTML.
An image proxy service, where your backend generates presigned URLs internally and serves the image bytes through its own endpoint, also sidesteps the issue. The public URL is your domain, it never expires, and the presigned URL is an implementation detail inside your server. The downside is bandwidth cost and latency versus a direct CDN-to-S3 path.
Content-addressable storage with immutable URLs (what most modern media platforms do) avoids the problem entirely. The URL is a hash of the content. It doesn't expire. Cache forever. This is the right answer at scale, but it requires a media pipeline that computes and stores hashes at upload time.
Conclusion
The fix took two hours. Finding it took longer because the failure mode was periodic, not consistent, and the logs pointed at S3 403s without explaining why the URLs were expiring mid-cache.
If you're building a static site that pulls assets from S3, the one check worth doing before shipping: compare your presigned URL TTL against your CDN max-age and your deployment frequency. If the math doesn't leave a comfortable margin, change one of the three numbers now rather than after a production incident.
If you want a second pair of eyes on your CDN and S3 configuration, the Sodio engineering team is reachable via the contact page.
FAQ
Why did the images break on a consistent six-day cycle rather than randomly? The cycle came from two fixed intervals colliding: a 7-day presigned URL TTL and a 24-hour build-deploy window. The first day of the URL's life was consumed by the pipeline, leaving six usable days before expiry. Once those six days elapsed, every CDN edge node serving cached HTML hit an expired URL simultaneously.
Can you use CloudFront signed URLs instead of S3 presigned URLs to avoid this? Yes, and it changes the failure mode. CloudFront signed URLs can be configured with longer expiries and are decoupled from S3 credential rotation. You can also use CloudFront signed cookies to authenticate entire sessions rather than individual assets, which scales better if you have many images per page.
What's the fastest way to check if this problem affects an existing site?
Pull the HTML source of a page that has been cached for more than five days. Extract an image URL. Check the X-Amz-Expires and X-Amz-Date parameters in the query string. Add them together and compare to the current time. If the result is in the past, your visitors are seeing 403s on that image.
Does this apply to Azure Blob Storage or Google Cloud Storage as well? Both have analogous mechanisms: Azure uses Shared Access Signatures (SAS tokens) and GCS uses signed URLs. The TTL-versus-cache-TTL collision is identical. The specific query string parameters differ, but the diagnostic approach and fixes are the same.
Is there a way to invalidate CDN cache automatically when presigned URLs rotate?
Not out of the box with most CDNs. You'd need to trigger a cache invalidation via the CDN API (CloudFront's create-invalidation, Fastly's purge API, etc.) as part of the build process that regenerates the URLs. This adds latency and cost to deployments and is generally the wrong solution compared to fixing the underlying TTL mismatch.
Have a project in mind? Contact Sodio Technologies to discuss your requirements and explore the right technology solution for your business.
/// Work with us
Talk to the engineers who'd build it
You'll get a technical scope, timeline and cost estimate from the people doing the work, not an account manager. In-house team, no subcontracting, since 2016.
