Blog · July 25, 2026
One tenant hammering "Upgrade" could have 429'd every other tenant's checkout.
Ledgenter bills every tenant through one shared Stripe account. stripe-checkout and stripe-portal are the two edge functions that call it directly — mint a Checkout Session, open the customer billing portal — and both are reachable by any authenticated tenant the moment they load the pricing page. Every other externally-triggered edge function in the repo (accept-invite, auth-exchange, session-exchange, provision-workspace, realtime-token, send-feedback-email) already carries a rate limit. These two didn't.
Why it read as fine
Read either function on its own and nothing looks wrong. Auth is checked first — no tenant claim, no call. The Stripe calls themselves are correct: subscriptions.list to check for an existing plan, a 409 guard against double-billing, checkout.sessions.create with the tenant's own price ID. Nothing here is a tenant-isolation bug; a tenant can only ever act on its own billing.
The gap isn't in what one tenant can do to its own account — it's in what happens to every other tenant's traffic. Stripe's API rate limit is account-wide, not per-caller. A script (or a bug, or one impatient customer double-clicking "Upgrade" in a retry loop) hammering stripe-checkout doesn't just cost itself a Stripe error — it burns the shared ceiling every tenant's checkout, portal, and webhook calls draw from. The failure mode is a billing-availability outage for customers who never touched the endpoint that caused it.
Why these two, specifically
Both functions make a third-party API call, which is what set them apart from most of the repo's other edge functions — a bad actor isn't just spending Ledgenter's own compute, they're spending a shared quota Stripe enforces on the account as a whole. Every other function with a rate limit already protects some other kind of shared or sensitive resource (an invite token, an auth exchange). These two protected a shared resource too; they just hadn't been looked at through that lens yet.
The fix
Same pattern already used everywhere else in the repo: auth_rate_bump, a fixed-window counter RPC from migration 0007, revoked from everything but service_role. Both functions now check a per-tenant bucket and a global bucket before making the Stripe call — 10 checkout attempts and 20 portal opens per tenant per minute, 300 and 600 respectively across all tenants combined, generous enough that no legitimate user ever notices. The counter fails open on a database error, same as every other rate limit in the codebase: a blip in the counter must never be the reason a paying customer can't upgrade. The actual security boundary — the tenant claim check — is unaffected either way.
No schema or contract change; this reuses an existing RPC, so it shipped and deployed in one pass, unattended.
The shape of the mistake
Every rate-limited function in this repo got its limit for a reason specific to what it protects — a token that shouldn't be brute-forced, an auth path that shouldn't be hammered. That made it easy to treat "does this need a rate limit" as a per-function judgment call instead of a repo-wide invariant, which is exactly how two functions that call a shared third-party account slipped through: nothing about their code was wrong, they just hadn't been checked against the one property that actually mattered here — not "can this tenant hurt itself," but "can this tenant's traffic exhaust something every other tenant depends on."
Read the RPC coverage sweep that runs the same systematic-diff habit against test coverage instead of rate limits. Start at ledgenter.com.