Launch-readiness teardown

A Success URL Is Not Proof of Payment

A customer finishes checkout and the payment provider sends their browser back to /success. Your app shows a green check. That is useful user experience. It is not evidence that the order was paid.

The browser controls the URL it visits. It can revisit that URL, change its query string, share it, or open it without completing checkout. If visiting /success?paid=true creates access, the paywall is a URL convention—not an authorization boundary.

The launch check is simple:

A success-page visit must create zero entitlements.

Reproduce the failure

The ShipTested public teaching harness contains this deliberately unsafe handler:

export function vulnerableHandleSuccessPage({ query, entitlements }) {
  const params = new URLSearchParams(query);

  if (params.get("paid") === "true") {
    entitlements.push({
      userId: params.get("user_id"),
      source: "browser-redirect",
    });
  }
}

Nothing here establishes that a payment provider accepted an order. Both paid and user_id came from the browser. A visitor can type:

/success?paid=true&user_id=user-b

The exploit test calls the handler with that query and verifies that it creates an entitlement. Run it with:

npm run test:failures

One naming detail matters: the exploit test passes when it successfully reproduces the vulnerable behavior. It is evidence of a failure, not launch approval.

This example is intentionally small. It does not claim that every checkout integration has this bug. It gives you a deterministic way to recognize and test one unsafe pattern in your own app.

State the invariant before choosing the fix

The fixed example makes the success page informational only:

export function handleSuccessPage() {
  return {
    status: 200,
    access: "pending-verified-webhook",
  };
}

Its launch test supplies the same hostile query and checks two outcomes:

assert.equal(result.access, "pending-verified-webhook");
assert.equal(entitlements.length, 0);

The model still returns status 200 and pending-verified-webhook. It demonstrates an informational response without creating access, regardless of the supplied query parameters.

Run the green checks with:

npm run test:launch

The checkout check passes only when a success-page visit creates no entitlement.

What should create access instead?

Put the authority on the server side. A common payment flow looks like this:

  1. An authenticated server endpoint maps an internal plan name to an allowlisted provider product or variant.
  2. The server attaches the signed-in internal user ID as checkout metadata. The browser does not choose the owner, price, or variant as trusted data.
  3. The success page shows a pending or processing state. It may read current entitlement state, but it does not write it.
  4. A server-side verified payment event creates the entitlement. The planned full ShipTested reference flow uses a signed webhook whose event type, paid status, store, variant, test mode, order ID, and internal user ID are checked before a state change.
  5. Duplicate deliveries become no-ops through a database-enforced boundary such as a transaction and appropriate unique constraints.

A signed webhook is not the only possible provider architecture. Some integrations retrieve and verify a checkout session server-to-server after the redirect. The invariant stays the same: browser-controlled redirect data does not grant access by itself.

The public checkout example proves only the negative redirect invariant. It does not yet contain the server-created checkout endpoint. That endpoint, durable database constraints, refund handling, and provider-specific integration belong to the full reference repository.

The public webhook model checks only that the custom user ID is a non-empty string. A production flow must resolve it to an eligible internal record before granting access.

Run the check against your staging app

Use a staging environment and test data:

  1. Copy the checkout success URL.
  2. Open it in a fresh session without placing an order.
  3. Refresh it several times.
  4. Add or change parameters such as paid, status, plan, variant, and user_id.
  5. Inspect the entitlement table or access-control result after every attempt.

The passing result is zero new access. A message saying “payment processing” is fine. A new entitlement, paid role, license, credit balance, or protected download is a failure.

Then complete a sandbox purchase and verify the separate server-owned fulfillment path. That second step matters, but it is outside what the small redirect harness proves.

The boundary of this proof

The repository is a dependency-free Node.js teaching harness with in-memory state. It demonstrates an unsafe redirect and a fixed invariant through executable tests. It is not a production payment integration, a security audit, or a certification. Passing this one test does not make an app ready to sell.

It does remove one dangerous ambiguity: the browser may report where it landed. It does not decide who paid.

Inspect the exact test

Run the broader launch-readiness checklist

Sources: Lemon Squeezy — Taking Payments, Create a Checkout API, and Passing Custom Data.

Test this on your own app before you sell it.