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.
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.
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.
