Skip to content
BDOT SOFTWAREBDOT Software

Insights / Security

Secure file downloads: signed URLs, expiry, and access control for web applications

A permanent file URL is an accidental access decision. Design secure downloads with signed URLs, short expiry, per-request authorization, and audit-ready logging.

Kiran Bandarupalli · 22 Oct 2026 · 6 min read

Illustration of a document connected to a locked URL chain link representing secure signed download links with expiry in web applications

Uploads get all the security attention: virus scanning, MIME sniffing, size limits, isolated processing. But the quieter side of file handling is where data actually leaks. A download URL that works forever, for anyone who holds it, is not a convenience — it is an access control decision you made by accident. Every forwarded link, every browser history entry, every log line that captures that URL hands the file to someone you never authorized.

Secure downloads have their own threat model. Unlike an API endpoint that checks a session token on every request, a file URL is often bearer by nature: possession is access. The fix is to stop treating download URLs as permanent addresses and start treating them as short-lived, single-purpose grants. This post covers how to design that, from signed URLs to audit trails.

The real threat model

Before choosing a mechanism, name what you are defending against. Downloads leak through four channels:

  • URL guessing. Predictable paths like /files/invoices/1234.pdf let an attacker enumerate other users' files by incrementing an ID.
  • Link forwarding. A legitimate user copies a download link into an email thread or a chat. If the URL is valid indefinitely, authorization ends at the original user.
  • History and logs. URLs live in browser history, proxy logs, and referer headers. A file URL containing a secret path is a credential sitting in plaintext everywhere it passed through.
  • Exfiltration at scale. Without expiry or per-request checks, one compromised link becomes a pipeline: an attacker can pull every file the URL pattern permits, undisturbed.

Each of these is a failure of the same idea: the URL carries the authorization, permanently. The remedy is to make authorization ephemeral and re-check it at the moment of download.

Stop serving files from your application process

The first architectural decision is separation. Your API server should never stream bytes from disk for routine downloads. It is slow, it ties up request threads, and it mixes two jobs: deciding who may download, and moving bytes.

The pattern that scales is authorize, then redirect: the user asks your API for a file, the API verifies identity and permission, then hands back a short-lived signed URL pointing at private object storage. The browser follows the redirect and downloads directly from storage. Your API spends milliseconds deciding; the CDN or storage layer does the heavy lifting. We covered the inbound half of this architecture in our secure file-upload pipeline — downloads are its mirror image, and the same private-bucket discipline applies.

Signed URLs, done right

A signed URL embeds its own authorization: the storage provider (or your own signer) issues a URL with an expiry timestamp and a cryptographic signature over the path, expiry, and any constraints. Whoever holds it can download, but only until it expires. The details determine whether this is genuinely secure:

  • Expiry in minutes, not days. Five minutes is plenty for a browser to start a download; fifteen is generous. Anything longer converts a grant back into a permanent link.
  • Sign at request time, after authorization. Never pre-compute signed URLs at file creation and store them. Generate one per request, after checking that this user may access this file right now.
  • Single-purpose scope. The signature should cover the exact object key, the HTTP method (GET), and optionally the response headers. A signature minted for one file must not work for another.
  • Prefer private buckets with no public URLs. If the same object is reachable through an unsigned public URL, the signed URL is theater. Buckets holding user files should deny anonymous access entirely.
  • Bind to the downloader when it matters. For highly sensitive files, tie the grant to the user's session or IP range, and require the signature verification to run through your own endpoint rather than storage alone.

A minimal signing example

Storage providers offer presigned URL APIs, but the underlying idea is portable — an HMAC over the resource and expiry:

import crypto from "crypto";

function signDownloadUrl(fileKey, userId, secret, ttlSeconds = 300) {
  const expires = Math.floor(Date.now() / 1000) + ttlSeconds;
  const payload = `GET\n${fileKey}\n${userId}\n${expires}`;
  const signature = crypto
    .createHmac("sha256", secret)
    .update(payload)
    .digest("hex");
  return `/api/files/${fileKey}?user=${userId}&exp=${expires}&sig=${signature}`;
}

// On download, reject anything expired, then re-verify the
// signature AND re-check that the user still has access.
function verifyDownload(req, secret) {
  const { exp, sig, user } = req.query;
  if (Number(exp) < Math.floor(Date.now() / 1000)) return false; // expired
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`GET\n${req.params.fileKey}\n${user}\n${exp}`)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

Note the deliberate redundancy: even with a valid signature, re-check the user's permission against your database before serving. Permissions change — a revoked team member's five-minute-old link should die with their access, not outlive it.

When you must stream through your own API

Sometimes the redirect pattern does not fit: files that need on-the-fly transformation, watermarking, or virus re-scans. In that case, stream from private storage through a thin download endpoint, and keep it thin on purpose:

  • Authenticate the request with the same session mechanism as the rest of your API — this endpoint is just another versioned API endpoint, not a special case with weaker auth.
  • Set Content-Disposition: attachment so browsers download rather than render the file inline. Rendering attacker-controlled HTML in your origin's context is a stored-XSS vector.
  • Support range requests so large downloads can resume; a failed 2 GB download that restarts from zero will be retried aggressively and hammer your endpoint.
  • Log every download: who, what, when, which grant. Downloads are access events, and your observability story should include them as first-class audit data.

The secure-download checklist

Run through this before shipping any file-serving feature:

  • ☐ No file is reachable through a permanent, unauthenticated URL — test this by fetching the raw storage URL in an incognito session.
  • ☐ Every download URL is generated per request, after an authorization check, with an expiry measured in minutes.
  • ☐ Signatures cover the exact object, method, and expiry; one file's grant never works for another.
  • ☐ Storage buckets deny anonymous access; there is no public fallback URL for private files.
  • ☐ Permissions are re-checked at download time, not just when the link was created.
  • ☐ Content-Disposition: attachment is set, and served content types never execute in your origin's context.
  • ☐ Every download is logged with user, file, timestamp, and the grant that authorized it — enough to answer "who pulled this file, and when?"
  • ☐ There is a way to invalidate access quickly: permission revocation, key rotation, or grant blacklisting, tested end to end.

Downloads are an access-control problem, not a storage problem

The teams that leak files rarely have broken cryptography. They have permanent URLs, public-by-default buckets, and download paths that never re-check permission. Treat every download as an authorization decision made at the moment of the request, give that decision a short life, and log it like it matters — because for compliance and incident response, it does.

If you are building file handling into a custom web application or hardening the delivery side of your cloud infrastructure, the upload pipeline and the download path should be designed as one secure file-handling story, not two disconnected features. And when the system grows, treat file delivery with the same delivery discipline as your deployments: prove what your pipeline ships and keep the platform repeatable for small teams. Talk to us if you want an outside review of your file-handling design.