VibeAudit · Claude Fable 5.1 reads a whole repo and grades it for launch. Free quick scan, $19 deep audit. Scan your own repo →
DEEP AUDIT · 2026-09-02
vercel/nextjs-subscription-payments
70–89 fix first
<70 not ready
This is the Vercel/Supabase/Stripe subscription starter: Supabase auth with RLS, Stripe Checkout and Customer Portal via Server Actions, and a webhook that syncs products/prices/subscriptions into Postgres. The foundations are sound (webhook signature verification, service-role key kept server-side, RLS on every table), but there are several bugs that will cost money or embarrass you with real customers: the checkout Server Action trusts a client-supplied price object including trial_period_days, the 'Manage' button on the pricing page silently creates a second subscription, the webhook returns 400 for every event type it doesn't handle (which will get the endpoint disabled if you 'select all events' as the README says), and the password-confirmation check never actually blocks anything. Fix the checkout action and the pricing button first.
76 files reviewed · claude-fable-5-1 · deep audit
Findings (15)
The Server Action signature is `checkoutWithStripe(price: Price, redirectPath)` (line 21). The whole `price` row comes from the browser. `price.trial_period_days` is fed straight into `calculateTrialEndUnixTimestamp` and used as `subscription_data.trial_end` (line 76), and `price.id` / `price.type` decide the line item and checkout mode (lines 59, 71-84). Nothing is re-read from the database. Server Actions are just POST endpoints; anyone can call this with `{ id: 'price_xxx', type: 'recurring', trial_period_days: 3650 }`.
A user can grant themselves a multi-year free trial on any plan, or start a checkout for any active price in your Stripe account (including internal/discounted prices that never appear in the pricing table). This is direct revenue loss and is trivial to exploit with devtools.
Accept only a `priceId: string`, look the price up server-side from `prices` (active = true), and use the DB row for `type` and `trial_period_days`. Example: `const { data: price } = await supabase.from('prices').select('*').eq('id', priceId).eq('active', true).single(); if (!price) throw new Error('Invalid price');`. Update `Pricing.tsx` to pass `price.id` instead of the object.
In utils/stripe/server.ts, change `checkoutWithStripe(price: Price, redirectPath)` to `checkoutWithStripe(priceId: string, redirectPath)`. Inside, after getting the user, load the price from Supabase: `const { data: price, error: priceError } = await supabase.from('prices').select('*').eq('id', priceId).eq('active', true).maybeSingle();` and throw `new Error('Invalid price.')` if missing. Use this DB row (not client input) for `price.id`, `price.type` and `price.trial_period_days`. Then in components/ui/Pricing/Pricing.tsx update `handleStripeCheckout` to call `checkoutWithStripe(price.id, currentPath)`. Reason: the Server Action currently trusts a client-supplied object, letting users set arbitrary trial lengths and choose any Stripe price.The button label is `subscription ? 'Manage' : 'Subscribe'` (line 192) but `onClick` always calls `handleStripeCheckout(price)` (line 189), which creates a new Checkout session in subscription mode for the same Stripe customer. Stripe happily creates a second subscription. Afterwards `getSubscription` in utils/supabase/queries.ts uses `.maybeSingle()` on `status in (trialing, active)` (line 16), which returns an error (ignored) and `null` when two rows match, so the app then believes the user has no subscription at all.
A paying customer who clicks 'Manage' to change plans is charged for a second subscription. Your app then shows them as unsubscribed on /account and on the pricing page, so they can do it a third time. This is the kind of thing that generates chargebacks and refund tickets.
When `subscription` exists, route the button to the Customer Portal instead of Checkout: `onClick={() => subscription ? handleStripePortal() : handleStripeCheckout(price)}` where `handleStripePortal` calls `createStripePortal(currentPath)` and pushes the returned URL. Also guard server-side in `checkoutWithStripe`: query the user's active subscription and return an error redirect if one exists. Harden `getSubscription` with `.order('created', { ascending: false }).limit(1).maybeSingle()`.
In components/ui/Pricing/Pricing.tsx, import `createStripePortal` from '@/utils/stripe/server' and add `const handleStripePortal = async () => { setPriceIdLoading('portal'); const url = await createStripePortal(currentPath); router.push(url); }`. Change the plan button's onClick to `subscription ? handleStripePortal() : handleStripeCheckout(price)`. In utils/stripe/server.ts `checkoutWithStripe`, after getting the user, query `supabase.from('subscriptions').select('id').in('status',['trialing','active']).maybeSingle()` and if a row exists throw `new Error('You already have an active subscription. Manage it from your account page.')`. In utils/supabase/queries.ts `getSubscription`, add `.order('created', { ascending: false }).limit(1)` before `.maybeSingle()`. Reason: the current 'Manage' button starts a second subscription checkout and the maybeSingle query breaks when a user has two active subscriptions.Events not in `relevantEvents` get `new Response('Unsupported event type', { status: 400 })` (lines 90-93). The README instructs users to 'Select all events' when creating the webhook, so the vast majority of deliveries (invoice.*, payment_intent.*, charge.*, customer.updated, etc.) will be 4xx. Handler failures also return 400 (line 86) instead of 5xx.
Stripe retries failed events for up to 3 days and, after sustained failures, emails you and disables the endpoint. Once disabled, subscription cancellations, payment failures and plan changes stop syncing and users keep access (or lose it) incorrectly. Your Stripe dashboard will also be red with thousands of 'failed' deliveries, hiding real failures.
Acknowledge irrelevant events with 200: `return new Response(JSON.stringify({ received: true }), { status: 200 })`. Return 500 (not 400) when a relevant handler throws so Stripe retries only genuine failures. Also subscribe only to the ten relevant events in the Stripe dashboard.
In app/api/webhooks/route.ts, change the `else` branch for non-relevant events (currently returning status 400 with 'Unsupported event type') to return `new Response(JSON.stringify({ received: true }), { status: 200 })` so Stripe does not treat unrelated events as failures. Change the catch block inside the relevant-events `try` to return status 500 instead of 400 so Stripe retries genuine handler failures. Also update README.md webhook instructions to select only the events listed in `relevantEvents` rather than 'Select all events'. Reason: Stripe disables endpoints that keep returning 4xx, which would silently stop subscription sync.`if (password !== passwordConfirm) { redirectPath = getErrorRedirect(...) }` sets a variable but does not return (lines 231-237). Execution continues to `supabase.auth.updateUser({ password })` (line 240) and `redirectPath` is then overwritten with the success redirect (line 251). The same pattern makes `isValidEmail` a no-op in `signInWithEmail` (line 42), `requestPasswordUpdate` (line 96) and `signUp` (line 173). Separately, the regex at line 10 rejects valid TLDs longer than 6 chars (`.technology`, `.photography`), so once the checks are made effective they will lock some users out.
A user who typos the confirmation gets their password set to the typo and sees 'Success! Your password has been updated.' Next login fails and they assume the app is broken. Empty passwords also pass through to Supabase (which returns its own error, so not exploitable, but confusing).
Return early: `if (password !== passwordConfirm) return getErrorRedirect('/signin/update_password', 'Your password could not be updated.', 'Passwords do not match.');`. Do the same `return` for each `isValidEmail` branch, and relax the regex to `/^[^\s@]+@[^\s@]+\.[^\s@]+$/`. Add a minimum length check (e.g. 8) before calling Supabase.
In utils/auth-helpers/server.ts: (1) in `updatePassword`, change the mismatch block to `return getErrorRedirect(...)` so the function exits before calling `supabase.auth.updateUser`; also return an error if `password.length < 8`. (2) In `signInWithEmail`, `requestPasswordUpdate` and `signUp`, change each `if (!isValidEmail(email)) { redirectPath = ... }` to `return getErrorRedirect(...)`. (3) Replace the `isValidEmail` regex with `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` so TLDs longer than 6 characters are accepted. Reason: these validation blocks assign a variable but never return, so the password is updated even when confirmation mismatches and invalid emails are sent to Supabase anyway.Quick wins
- · Add `import 'server-only'` at the top of utils/supabase/admin.ts so the service-role client can never be pulled into a client bundle by accident.
- · Exclude `/api/webhooks` from the middleware matcher in middleware.ts so every Stripe delivery doesn't trigger a Supabase auth round-trip.
- · Replace the deprecated `stripe.redirectToCheckout({ sessionId })` in Pricing.tsx with returning `session.url` from checkoutWithStripe and `router.push(url)`.
- · Wrap `handleRequest` in utils/auth-helpers/client.ts in try/catch and await it in NameForm/EmailForm so the loading state resets and thrown Server Action errors show a toast instead of a stuck spinner.
- · Set `autoComplete="new-password"` on the Signup and UpdatePassword password inputs and `type="email"` on the EmailForm input.
- · Replace the hard-coded 'Freelancer' highlight in Pricing.tsx (line 165) with a `metadata.featured` flag on the Stripe product.
- · Add an `app/error.tsx` and `app/not-found.tsx` so failures don't render the default Next.js error screen.
- · Enforce the 64-character limit for full_name server-side in updateName, not just via the input maxLength.
What's already good
- · Stripe webhook signatures are verified with constructEvent before any processing, and the raw body is read correctly with req.text().
- · Service-role Supabase client is confined to utils/supabase/admin.ts and only used from Server Actions and the webhook route; the browser only ever sees the anon key.
- · Row Level Security is enabled on every table with sensible policies: customers has no client policies at all, users/subscriptions are scoped to auth.uid(), products/prices are read-only.
- · Subscription sync re-fetches the subscription from Stripe inside the webhook rather than trusting event payload ordering, so out-of-order deliveries converge to the correct state.
- · Auth flows use @supabase/ssr with cookie-based sessions refreshed in middleware, and the callback routes exchange PKCE codes server-side.
- · Server Actions consistently check supabase.auth.getUser() before touching Stripe, so checkout and portal creation require an authenticated session.
Do this first
- Fix the two money bugs: make checkoutWithStripe accept only a priceId and load the price server-side (F1), and route the 'Manage' button to the Customer Portal plus block checkout for users with an active subscription (F2).
- Make the webhook return 200 for irrelevant events and 500 for handler failures, pin the Stripe apiVersion, and add the unique constraint on customers.stripe_customer_id (F3, F7, F9).
- Fix the dead validation returns in utils/auth-helpers/server.ts so password mismatch actually blocks, and make updateName write to public.users (F4, F5).
- Upgrade Next.js to the latest 14.2.x and rebuild (F6).
- Tighten createOrRetrieveCustomer (metadata-based lookup, verified-email guard, deleted-customer handling) and confirm email confirmations are enabled in the production Supabase project (F8, F14).
- Add ON DELETE CASCADE migrations, surface query errors instead of swallowing them, and lock down env var handling for site URL and Stripe keys (F10, F11, F12, F15).
- Run an end-to-end test in Stripe test mode: subscribe, click Manage, change plan in portal, cancel, delete user, and confirm every step updates the subscriptions table.
Fixed things? Re-audit.
Run a new scan on the updated repo. Use a 5-pack key or pay per audit.