ShipToCash
Payments Intermediate

Paid Download Lab: Expiring Links, Public URLs, and Refunds

Run a private download experiment: verify bytes, reject altered links, revoke refunded access, and reproduce why redirects to public files stay exposed.

Peng Zhou Peng Zhou · · Updated November 2, 2026 · 1,122 words · 6 min read

A download URL can expire while the file remains publicly accessible. The mistake is checking a short-lived token and then redirecting to a permanent public file: the browser learns that destination and can request it directly later.

This guide reproduces that failure using a public canary file, then checks a private-delivery implementation. Both are in the separate verification repository. The recorded evidence comes from a local Node server built with Astro 7.4.4, Node adapter 11.1.9, and Node 22.23.4. No real product, payment, buyer, or cloud bucket was used.

Decide whether you need custom delivery

For a one-off ZIP, PDF, or template, hosted file delivery may be sufficient. Lemon Squeezy’s product documentation describes attaching files to products. Your static Astro sales page can link to that hosted checkout without operating its delivery service.

This experiment addresses the other case: your application needs to authorize downloads from private storage. That requires server-side logic, even if the sales page is static. A browser ?success=true flag cannot prove payment. Stripe’s fulfillment documentation also separates fulfillment from the customer’s return to a landing page. Wiring the checkout side with Stripe Payment Links is a separate task and needs no server for a single product.

Run the built application and checks

git clone https://github.com/zhoupeng20188/shiptocash-verification-labs.git
cd shiptocash-verification-labs
git checkout 6537765e7d9493673e744602e388f5cd06b7a2c9
nvm install
nvm use
cd payment-delivery-lab
npm ci
npm run verify

If you do not use nvm, install the Node version in .nvmrc another way. Verification builds the server, uses a temporary database and private directory, exercises actual HTTP routes, and writes fresh output under runs/.

The reference run on November 2, 2026 at 02:23:40 UTC passed 45 checks across this case and the webhook experiment. Read the individual results rather than treating the count as a security certification.

Experiment 1: an expired gate, a working public destination

The deliberately broken endpoint is disabled by default. The verifier enables it only for this experiment, and the endpoint can redirect only to a harmless public canary. It never exposes the private ZIP.

The sequence is:

  1. Issue a correctly signed link with a two-second lifetime.
  2. Request the broken endpoint and observe its 302 destination.
  3. Save that public destination URL.
  4. Wait until the same signed link expires.
  5. Request the expired gate, then the saved destination directly.

The recorded output was:

public-redirect-survives-token-expiry:
  initialRedirect: 302
  expiredGate: 410
  publicDestination: 200
  privateDataUsed: false

The token checker rejected the expired request correctly. The public file remained accessible because its own URL had no authorization boundary. The experiment distinguishes a broken delivery architecture from a broken HMAC calculation.

Experiment 2: return private bytes instead of a public redirect

The working implementation stores the fixture outside Astro’s public output. After validating the token, it reads the durable order state, confirms that the order remains paid for the configured product, and returns the allowed file bytes.

The download implementation uses a fixed server-side asset mapping. It does not join an arbitrary user-supplied path into a filesystem location. The database directory and signing key come from the local server environment.

For the successful download, the run recorded:

ObservationActual result
HTTP status200
Content typeapplication/zip
Bytes197
RedirectNone
Cache controlprivate, no-store
Direct /private/starter-kit.zip request404

The verifier compared the complete response bytes to the fixture and recorded their SHA-256:

b571244ceadd86693a2aa7328fac4dba3b1b48a897d0bad1fc6d85926c88f63c

It also scanned the built client output: no private ZIP and no fixture signing keys appeared there. That observation applies to this build and these keys, not every possible way to leak a secret.

Bind the token to the order, asset, and lifetime

The link signature covers a versioned representation of the order ID, fixed asset name, issuance time, and expiry. A separate key signs downloads; the webhook key is not reused.

The endpoint requires each query field exactly once, rejects malformed timestamps, and enforces a maximum five-minute lifetime. It authenticates the fields before accepting the time bounds.

Request in the recorded runActual status
Change the order ID without resigning401
Extend the expiry without resigning401
Request a different asset path403
Add a second expiry parameter400
Empty or non-integer expiry400
Correctly signed but expired link410
Signed future issuance or excessive lifetime400
Missing private object for a paid order503
Open the download with only ?success=true400

Expired links in most checks are created with an already-expired signed timestamp to avoid unnecessary waiting. The public-canary experiment specifically waits for its original short-lived link to expire.

Expiry is not single-use access

The verifier requests the valid link twice and receives 200 both times. That is expected bearer-token behavior. Someone who receives the link may share it before it expires.

Single-use access would need an additional persistent consumption rule, including a decision about interrupted downloads and retries. The lab does not implement or claim that behavior. Likewise, revocation cannot remove a file already downloaded to a buyer’s computer.

Recheck entitlement after a refund

A valid token is not enough when an order has been revoked. Every download reads the current durable entitlement.

The synthetic full refund returned 200 revoked. The original still-unexpired download link then returned 403. A later signed paid snapshot did not restore access, and restarting the server preserved the revocation. The webhook case explains the transaction and terminal-revocation policy.

To inspect the flow manually, start npm run demo in one terminal. In another, run:

npm run send -- paid-order
npm run link -- 73001

Request the printed local link. Then run npm run send -- refunded-order and request that same link again. The local operator issues the link directly from the database; the example contains no public order-ID-to-download endpoint and no customer login system.

What this case does not establish

The private store is a temporary local directory, not an R2 or S3 integration. A cloud implementation needs its own object permissions, signed-URL configuration, and tests. R2’s presigned URL documentation describes that storage-specific option.

The synthetic orders cover one product and full refunds. Buyer authentication, email delivery, partial refunds, subscriptions, distributed persistence, real provider callbacks, and production rate limits are outside the experiment. The supported server command binds only to loopback and uses intentionally non-secret teaching credentials.

For a simple file sale, provider-hosted delivery remains the shorter implementation. If a paid buyer cannot find their receipt, start with the missing-download triage before building a custom delivery service.

Frequently Asked Questions

Is this a real payment test?

No. It is a recorded local HTTP and SQLite experiment with synthetic orders, non-secret teaching keys, and a 197-byte fixture ZIP.

Does an expiring signed link prevent sharing?

No. The lab confirms that the same bearer link can be reused before expiry. Single-use access requires additional persistent state.

Can a refund retract a file already downloaded?

No. The lab revokes subsequent server requests. It cannot remove bytes a customer has already received.

Peng Zhou

Peng Zhou

Founder of ShipToCash. I build small software products and write about the part nobody teaches you: getting your app deployed, taking payments, and understanding operational requirements. Guides include documentation references and worked examples; see the editorial standards for how they are prepared.

More about me →

Related Articles

Payments

Why Buyers Never Get the Download Email

Buyer paid, no email arrived. Fix it in order: unpaid orders, the signed receipt link you already have, and the Cloudflare record that stalls verification.