CodeKits.shopNew developer offer

Deliver protected downloads after verified payment

Separate payment verification from file access with durable entitlements, scoped download tokens, expiry checks and private storage.

By Jonathan · 4 min read

Published

Verified payment creates an entitlement that gates a private download
In this article

The customer has paid, but putting a ZIP under a public URL would let anyone who learns the path download it. Instead, create a durable entitlement after verified payment and let a separate download endpoint enforce it. A receipt page is a status screen, not evidence of payment.

The outcome is an access-control design for a single digital purchase, with token creation code and a bounded database query. You need an authenticated Node.js application, private file storage and a transactional database. No live payment or storage service is used here; test with synthetic buyers and a harmless text file.

Create access once, after fulfilment

Start with idempotent Stripe fulfilment. Store one entitlement for the internal order and asset. Resolve the asset from your server-owned catalogue, not a filename sent by the browser or arbitrary event metadata.

Use fields such as id, order_id, buyer_id, asset_id, created_at and nullable revoked_at. Enforce uniqueness on (order_id, asset_id). A repeated event finds the same entitlement. Delayed payment methods must wait for confirmed payment; Stripe's fulfilment documentation explains why completion and payment are different states.

Mint a short-lived capability

After the signed-in buyer requests a download, check the entitlement and create a random token. Store only its digest, plus entitlement ID and an expiry. This example uses a bearer token; possession is enough, so treat it as a temporary credential.

download-token.mjs
import { randomBytes, createHash } from "node:crypto";
 
export const digestToken = token => createHash("sha256")
  .update(token, "utf8").digest("hex");
 
export function issueToken(now = Date.now()) {
  const token = randomBytes(32).toString("base64url");
  return {
    token,
    digest: digestToken(token),
    expiresAt: new Date(now + 10 * 60 * 1000),
  };
}
 
export function validTokenShape(token) {
  return typeof token === "string" && /^[A-Za-z0-9_-]{43}$/.test(token);
}

Use a unique binary or ASCII column for the digest. The format check bounds work and rejects malformed input, but is not authorization. The database lookup performs authorization. Hashing a high-entropy random token differs from password hashing: this token is not a human-chosen, guessable password.

download-access.sql
SELECT e.asset_id
FROM download_tokens AS t
JOIN entitlements AS e ON e.id = t.entitlement_id
WHERE t.token_hash = ?
  AND t.expires_at > UTC_TIMESTAMP(6)
  AND t.revoked_at IS NULL
  AND e.revoked_at IS NULL
LIMIT 1;

Bind the digest through a prepared query. If the policy also requires an account session, add e.buyer_id = ? using the authenticated server identity. Do not accept that identity from the URL. Choose deliberately between account-bound access and convenient bearer links; they have different sharing behaviour.

Keep the file private all the way through

Map the returned asset ID to a storage key from trusted database data. Do not append a requested filename to a filesystem directory. Stream the authorised file, or issue a very short-lived object-storage signed URL. The latter remains usable until its own expiry even if the entitlement is revoked meanwhile.

Set Cache-Control: private, no-store and a safe Content-Disposition filename. Avoid logging the token, including in reverse-proxy access logs. A query-string link may appear in browser history and referrers; use a dedicated download page with Referrer-Policy: no-referrer, and consider a POST exchange for a short-lived session. Email providers and recipients can still see emailed links.

Expiry is checked when access starts. It does not necessarily stop an open stream. Revocation cannot recall a saved file. Describe these limits to the product owner instead of promising that links prevent copying.

Test access, not just the happy path

Create buyer A with a paid entitlement and buyer B without one. A valid token must return only A's entitled asset; an unpaid order, expired token, revoked entitlement and random token must not return file bytes. Use the same outward error for invalid and expired tokens if enumeration is a concern.

Replay fulfilment and verify the entitlement count does not grow. Test interrupted downloads and HTTP Range requests before adding download-count limits: counting requests is not counting completed downloads. Test a storage failure after authorization and ensure it produces a retryable error without granting another product.

If a download works after logout, check whether you chose bearer access. If a private file opens without a token, audit public directories and storage permissions first. For integrated purchase and delivery workflows, see CodeKits custom Next.js stores.