DEV Community

kerry convery
kerry convery

Posted on

How would you handle expirying presigned urls on the frontend?

We have a case where we use presigned S3 urls as the src for image tags. These urls are valid for 5 minutes.

Our API returns a url for each available image size.

The frontend gets a list of user profiles which includes two profile photo urls for each profile, a url for a thumbnail and a url for a large view of the photo. All urls are valid for 5 minutes. The frontend caches the urls and displays the list of profiles. When a user clicks on a profile photo we display the larger version of the profile photo.

The problem is that it might be more than 5 minutes when a user clicks on a thumbnail so when the frontend displays the larger version of the profile photo the url for that image has already expired so we can't display it.

How to solve this problem?

One option is to use a hidden image tag for the larger image and make it visible when the user clicks or hovers over it. Because it's hidden, the browser will download it when the page is first rendered. The problem is that the list of profiles can be 100 items long which means that a lot of images will be downloaded and most will probably not be clicked on.

Another option is the extend the expiry time of the url to 12 hours. While this works it doesn't help if the user leaves their browser open for longer.

Finally, we thought maybe we can fetch the image presigned url when the user clicks on the thumbnail.

Why do we set an expiry on the image urls? It is because a user can hide their profile and when hidden their profile photo should not be available for viewing. So we set the url expiry to 5 minutes and we do not serve a new url if the profile has since be hidden.

Any thoughts on other ways to solve this issue? I feel that this must be a common problem and therefore there must be acceptable solutions that frontend devs use.

Top comments (2)

Collapse
 
kerryconvery profile image
kerry convery

We ended up extending the expiry to 12 hours. Although this does not completely resolve the problem and is a bit of a hack imho it may be good enough for our use case.

Alternatively, we expose our own API which returns a redirect to S3. This way we don't have large amounts of data flowing through our API and instead let S3 take this load plus we can manage access to the images. This problem with this approach is that the API will be public and so we can't limit access to only logged in users, that is, we cannot attach an auth token when the browser downloads the image for the image tag. We could use an auth cookie but my team doesn't have control over the frontend, that is another team, and that would be a big change.

Collapse
 
pannous profile image
pannous

Great question, Kerry — this is a common pain point when combining S3 presigned URLs with browsing UIs where timing is unpredictable.

Here are three approaches ranked by complexity:

1. On-Demand URL Endpoint (Simple, Recommended)

Add GET /api/photos/{userId}/url that checks profile visibility and returns a fresh presigned URL. On the frontend, fetch lazily when the image enters the viewport:

const observer = new IntersectionObserver(async (entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      const res = await fetch(`/api/photos/${entry.target.dataset.userId}/url`);
      const { url } = await res.json();
      entry.target.src = url;
      observer.unobserve(entry.target);
    }
  }
});
document.querySelectorAll('img[data-user-id]').forEach(img => observer.observe(img));
Enter fullscreen mode Exit fullscreen mode

URLs are generated moments before use — you keep the 5-minute TTL and hidden profiles are enforced every time. For ~100 thumbnails, only visible ones trigger requests.

2. CloudFront Signed Cookies (Medium)

Place CloudFront in front of S3. Issue a signed cookie at login granting access to /photos/* for a longer TTL. Image tags use plain CloudFront URLs — no per-image presigning needed. Hidden profiles are excluded from the cookie policy.

Trade-off: Requires CloudFront setup + key pair management. Harder to do per-image granular visibility.

3. Lambda@Edge (Advanced)

CloudFront Function on viewer-request validates auth (JWT in cookie), checks profile visibility, and signs requests to S3 on the fly. Per-request, per-profile authorization at the CDN edge with sub-millisecond overhead.

Trade-off: Most complex. CloudFront Functions have 10KB code limit and no network access, so auth must be self-contained.

My recommendation: Start with option 1. Minimal backend work, solves the problem completely, keeps security tight. The 12-hour TTL you have now is a real security gap for hidden profiles — a leaked URL gives access for hours. Option 1 closes that while simplifying frontend logic.

-- Karsten / Pannous