Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 4 min read

Why Disabling Downloads Does Not Revoke a Signed URL

A photographer disables downloads for a private client gallery. The download button disappears immediately. An API test confirms that new download requests return 403. Then someone pastes a previously generated file URL

A photographer disables downloads for a private client gallery. The download button disappears immediately. An API test confirms that new download requests return 403.

Then someone pastes a previously generated file URL into another browser.

The original image still downloads.

Nothing is necessarily broken in the storage service. The application changed its permissions, but an earlier request had already produced a separate credential: a signed URL.

This is a common trap when building private media delivery. The permission check and the actual file transfer happen in different systems, at different times.

A signed URL is a temporary credential

Consider this request flow:

Browser β†’ Application: May I download asset 42?
Application β†’ Database: Check current permissions
Application β†’ Browser: Return signed storage URL
Browser β†’ Storage: Fetch asset using that URL

The last request does not automatically consult your application database.

For Amazon S3, a presigned URL is a bearer token. Anyone holding it can use it while it remains valid, subject to the signing credentials and applicable storage policies. It can also be reused; it is not a single-use download ticket. These behaviors are documented in the S3 presigned URL guide.

Changing an application flag prevents future URL issuance. It does not automatically invalidate URLs already issued.

That distinction belongs in the architecture before it becomes a support incident.

Separate preview permission from original-file permission

A gallery often needs to show a photograph before allowing access to its original file. Product requirements such as those described in this gallery access and download FAQ make that distinction concrete: viewing, selecting, and downloading are separate actions.

Model them separately.

A preview should reference a distinct derivative, not an original image that CSS happens to display at a smaller size.

Asset
  id
  galleryId
  previewKey
  originalKey

Gallery
  id
  downloadsEnabled
  expiresAt

The browser inevitably receives the bytes needed to display a preview. Download controls cannot prevent someone from retaining those bytes or taking a screenshot.

What the backend can enforce is whether it releases the original.

Do not include original-file URLs in gallery JSON when downloads are disabled. Hiding the button after sending the URL is already too late.

Issue URLs only when a download is requested

For this design, the application issues an original-file URL only after checking current access.

The following TypeScript uses application-specific adapters. sessionFor verifies the session, getAccess reads current access state, and signOriginal wraps the storage provider’s signing API.

async function issueDownload(
  request: Request,
  assetId: string
): Promise<Response> {
  const headers = { "Cache-Control": "no-store" };

  const session = await sessionFor(request);
  if (!session) {
    return new Response(null, { status: 401, headers });
  }

  // Object-level lookup, not a client-supplied storage key.
  const asset = await assets.findById(assetId);
  if (!asset) {
    return new Response(null, { status: 404, headers });
  }

  const access = await galleries.getAccess(
    session.id,
    asset.galleryId
  );

  const now = Date.now();

  if (
    !access?.canView ||
    !access.downloadsEnabled ||
    access.expiresAt <= now
  ) {
    return new Response(null, { status: 403, headers });
  }

  const ttlSeconds = Math.min(
    60,
    Math.floor((access.expiresAt - now) / 1000)
  );

  if (ttlSeconds < 1) {
    return new Response(null, { status: 403, headers });
  }

  const url = await signOriginal({
    key: asset.originalKey,
    expiresInSeconds: ttlSeconds
  });

  return Response.json({ url }, { headers });
}

Here, every access grant has a finite expiration timestamp. The storage bucket remains private, and the signing service has only the permissions it needs.

The asset-to-gallery relationship comes from the database. A caller cannot substitute an arbitrary object key or claim that an asset belongs to another gallery.

The 60-second lifetime is an example policy, not a universal recommendation. Large files, slow networks, and retry behavior affect the appropriate value.

Define what revocation actually guarantees

There is still a race.

A request can pass its permission check just before another request disables downloads. The signer may then issue a URL based on that earlier decision.

Even perfectly serialized issuance cannot recall a credential that has already reached the browser.

A practical contract for direct signed downloads is:

Disabling downloads stops new authorization decisions from granting access. Previously issued URLs may remain usable until their expiration.

If the requirement is to deny every new media request immediately after revocation, direct storage URLs alone are insufficient.

Use an application gateway or an edge authorization layer that checks current permission before serving each request, including cache hits. If that layer caches authorization decisions, the cache lifetime becomes part of the revocation delay.

Neither approach can retrieve bytes already delivered.

URL expiration and cache expiration are different clocks

Suppose a URL expires after one minute, but its response is cacheable for an hour. A browser may reuse the stored response without making another request to storage.

For sensitive originals, a conservative starting point is:

Cache-Control: no-store

Apply this to the actual file response, not only the JSON containing its URL. Configure object metadata or supported response overrides accordingly.

As the Cache-Control reference explains, private still permits browser caching, while no-cache permits storage but requires validation before ordinary reuse. Neither means β€œdo not store.”

Also audit service workers and custom CDN rules. A cache lookup that bypasses authorization can undermine an otherwise correct permission system.

Test the URL, not just the button

A useful integration test follows the complete sequence:

  1. Obtain a signed URL while downloads are enabled.
  2. Disable downloads.
  3. Confirm that requesting a new URL fails.
  4. Request the old URL from a separate client.
  5. Repeat after its expiration, without a local cached response.

For the direct-download design, step four may succeed. That is the behavior the policy must acknowledge.

Test interrupted transfers too. S3 checks expiration when a request begins; an existing transfer can continue past expiry, while a later retry may fail. Requesting a replacement URL should require fresh application authorization.

Keep signed query strings out of analytics and application logs. Record asset IDs, authorization outcomes, and request IDs instead.

A disabled button describes the interface. A denied authorization check governs future credentials. An expired signed URL governs future storage requests. Treating these as separate events makes the system’s security promises precise.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.