A Cache Doesn't Know a Token Expired

Retrieving a private QR code through a signed URL and CloudFront, and the TTL mismatch that quietly keeps expired access alive in the cache.

August 29, 20264 min read12 / 12

Somewhere after registration, an SMS arrives: the QR code is ready. That's where this design closes its loop, the user logs in and finally sees the thing the entire system was built to deliver.

Logging in reuses what already exists

Login goes through the same OTP challenge the passwordless frontend post already built for registration. Cognito issues tokens, and those tokens authorize every backend call from here on, no new authentication mechanism required for this last step.

Why the QR code lives behind a private bucket

The QR code itself sits in S3, generated back during vehicle registration. Making that bucket public would be a straightforward way to serve it, and also a straightforward way to leak it. A public bucket means anyone who guesses or finds another user's file path can view their fuel pass QR code. The bucket stays private on purpose.

Getting a private file to an authorized user

Private storage still needs a way to reach the person it belongs to. The path here is a signed URL, sometimes called a presigned URL, a temporary, cryptographically limited link to one specific object.

The flow after login:

  1. The frontend calls a new API Gateway endpoint, something like /signed-url, carrying the user's token.
  2. API Gateway authorizes the request against Cognito, exactly the pattern already covered for the registration write.
  3. A Lambda function generates a signed URL scoped to that user's specific QR image, valid for a short window, 5 minutes in this design.
  4. That URL goes back to the frontend, which uses it to actually fetch the image.

The fetch itself doesn't go straight to S3. It goes through CloudFront, using a distinct path pointed at the private QR bucket as its origin, the same reverse-proxy pattern the entire application already runs behind. CloudFront forwards the request, S3 returns the image, and CloudFront caches it at the edge, exactly the caching behavior discussed all the way back in the frontend hosting post. The second time the user opens the app, the QR code comes back from the cache instantly, no trip to S3 at all.

The mismatch that undoes the expiry

Here's where a detail easy to overlook actually matters. The signed URL has a TTL, 5 minutes in this example. CloudFront's cache also has a TTL, configured completely separately.

If CloudFront's cache TTL is longer than the signed URL's TTL, expiry stops meaning anything. Say CloudFront caches for an hour. The signed URL is only supposed to be valid for 5 minutes, but CloudFront doesn't know or care about that, it's just holding onto bytes it already fetched. For the full hour, anyone with that cached CloudFront URL keeps getting the image served, regardless of what the underlying signed URL would have allowed by that point.

The signed URL should stop working after 5 minutes, but a longer CloudFront cache TTL keeps serving the cached image well past that point ExpandThe signed URL should stop working after 5 minutes, but a longer CloudFront cache TTL keeps serving the cached image well past that point

The fix is a rule, not a workaround: CloudFront's cache TTL should be equal to or shorter than the signed URL's TTL. When the cache expires at or before the signed URL would have, a cache miss forces a fresh request, a fresh signed URL, and a fresh authorization check, instead of quietly serving content whose access window has technically already closed.

Closing the loop

Registration, vehicle verification, and now retrieval, this closes the full design behind National Fuel Pass. Every piece traces back to the same handful of ideas that opened this series: push complexity into an architecture built from managed services, decouple what needs to scale independently, plan for failure instead of hoping around it, and never let a convenient shortcut quietly undercut a security guarantee that was supposed to hold.

The course moves from here into hands-on implementation, actually building these services rather than just designing them on a whiteboard. That's where the next set of notes picks up.

The Essentials

  1. A private bucket for user-specific files isn't excessive caution. A public one is an access control decision made by accident.
  2. A signed URL trades a permanent public link for a temporary, scoped one. Short-lived by design, generated only for an authorized caller.
  3. Routing private content through CloudFront still gets you edge caching. The same reverse-proxy pattern that fronts the whole app works for a private origin too.
  4. A cache doesn't know when the URL behind it expired. It only knows when its own TTL runs out.
  5. CloudFront's TTL should never exceed the signed URL's TTL. Otherwise the cache quietly keeps serving access that was supposed to have already ended.

Further Reading and Watching