VibeAudit

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

nextjs/saas-starter

Download .md
47/100
NOT READY
90+ ship it
70–89 fix first
<70 not ready

Next.js 15 (canary) SaaS starter with email/password JWT auth, Drizzle/Postgres, teams with owner/member roles, and Stripe subscriptions. The scaffolding is reasonable (webhook signatures verified, bcrypt, zod on every action, soft deletes), but the authorization layer only exists in the UI: server actions never check team role, invited users are stamped 'owner', the Remove Member action is broken by a zod type mismatch, and the invitation flow lets anyone who signs up with an unverified email join a team by guessing a small integer. On the money side, a paid checkout is only recorded via the browser redirect, so a closed tab means a paying customer with no plan, and the API exposes the user's bcrypt hash to the browser. Fix the role checks, the checkout/webhook provisioning, and the /api/user leak before taking real customers.

Next.js 15.6 canary (App Router, PPR, Server Actions)React 19TypeScriptDrizzle ORM + postgres.jsPostgreSQLStripe (Checkout, Billing Portal, Webhooks)jose (HS256 JWT sessions)bcryptjszodSWRTailwind CSS v4 + shadcn/ui (radix-ui)

53 files reviewed · claude-fable-5-1 · deep audit

Findings (20)

What's wrong

inviteTeamMember (lines 399-459) loads the caller's team via getUserWithTeam and immediately inserts an invitation with the caller-chosen role ('member' | 'owner'). There is no check of teamMembers.role or users.role. The only gate is the disabled attribute on the form in app/(dashboard)/dashboard/page.tsx lines 215/224/245, which is client-side and trivially bypassed by calling the action directly. Also the UI uses user.role from the users table, which signUp always sets to 'owner' (see F9), so the check would be wrong even if it were enforced.

Impact

Any team member can invite an arbitrary email as an owner, escalating themselves (via a second account) or an accomplice to owner and then removing the real owners or managing billing.

Fix

Look up the caller's row in teamMembers for this team and require role === 'owner' before inserting. Example: const me = await db.query.teamMembers.findFirst({ where: and(eq(teamMembers.userId, user.id), eq(teamMembers.teamId, userWithTeam.teamId)) }); if (me?.role !== 'owner') return { error: 'Only team owners can invite members' };

Paste into Cursor / Claude Code
In app/(login)/actions.ts, inside the inviteTeamMember server action (after the `if (!userWithTeam?.teamId)` check), query team_members for the row where userId = user.id and teamId = userWithTeam.teamId and return { error: 'Only team owners can invite members' } unless that row's role is 'owner'. Do the same check in removeTeamMember. Reason: authorization is currently only enforced by disabled form inputs in the client; the server action accepts any authenticated team member and any role value.
What's wrong

removeTeamMemberSchema declares memberId: z.number(). validatedActionWithUser (lib/auth/middleware.ts line 47) parses Object.fromEntries(formData), where every value is a string. z.number() rejects '3', so the action returns { error: 'Expected number, received string' } on every submit and the delete on line 375 is never reached.

Impact

The Remove button on the team page never works. Owners cannot remove members after launch; the error message shown is a raw zod message.

Fix

Use coercion: memberId: z.coerce.number().int().positive(). Then also add the owner check from F1 and prevent removing the last owner.

Paste into Cursor / Claude Code
In app/(login)/actions.ts change removeTeamMemberSchema from `memberId: z.number()` to `memberId: z.coerce.number().int().positive()`. Reason: server actions receive FormData whose values are always strings, so z.number() rejects every submission and the Remove Member button is non-functional.
What's wrong

Once F2 is fixed, removeTeamMember deletes any teamMembers row matching (memberId, caller's teamId) with no check that the caller is an owner, no protection against removing the last owner, and no check against removing yourself. The UI hides the button for index <= 1 (dashboard/page.tsx line 156), which is unrelated to role and is client-only.

Impact

A regular member can kick every owner out of the team and take control of a team that has an active paid subscription.

Fix

Require caller role 'owner' (see F1); refuse to remove a member whose role is 'owner' if they are the last owner; refuse memberId that maps to the caller unless another owner exists. Wrap in a transaction.

Paste into Cursor / Claude Code
In app/(login)/actions.ts removeTeamMember: before the delete, (1) load the caller's team_members row and return an error unless role === 'owner'; (2) load the target row by id+teamId and, if target.role === 'owner', count remaining owners in the team and refuse if it would leave zero; (3) run the checks and delete in a db.transaction. Reason: the action currently trusts any authenticated team member to delete any membership row in the team.
What's wrong

getUser() (lib/db/queries.ts line 26) does db.select() with no column list, returning passwordHash, role, deletedAt, etc. app/api/user/route.ts returns it via Response.json(user), and app/layout.tsx line 34 passes getUser() into the SWRConfig fallback, so the hash is also serialized into the HTML/RSC payload of every page. /api/team similarly ships stripeCustomerId and stripeSubscriptionId to every member.

Impact

Every logged-in page load leaks the user's bcrypt hash to the client, browser extensions, and any XSS. Offline cracking of weak passwords becomes possible from a single captured response; Stripe IDs leak to non-owner members.

Fix

Select only safe columns in getUser (or strip before returning): const { passwordHash, ...safeUser } = user; Return safeUser from /api/user and the layout fallback. For /api/team, omit stripe* columns for the client-facing shape.

Paste into Cursor / Claude Code
In lib/db/queries.ts, change getUser() to select explicit columns { id, name, email, role, createdAt, updatedAt } from users (never password_hash), and create a separate getUserWithPasswordHash() for the auth code paths in app/(login)/actions.ts and lib/auth/middleware.ts that need to call comparePasswords. Update app/api/user/route.ts and app/layout.tsx to use the safe version. In getTeamForUser, exclude stripeCustomerId and stripeSubscriptionId from the returned team columns. Reason: the full users row including the bcrypt hash is currently JSON-serialized to the browser on every page.
What's wrong

signUp accepts inviteId from a hidden form field and matches invitations where id = parseInt(inviteId), email = the email the attacker typed, and status = 'pending'. Email addresses are never verified anywhere in the app, and invitation ids are a serial integer. An attacker who knows or guesses that alice@corp.com was invited can sign up as alice@corp.com with inviteId 1..N and join the team with whatever role was granted (possibly 'owner'). Invitations also never expire and no email is actually sent (TODO on line 454), so the legitimate flow does not work at all.

Impact

Pending invitations are claimable by anyone who can guess an email; combined with F1 this allows owner-level takeover of a paying team. Meanwhile real invitees never receive a link.

Fix

Add a random token column (crypto.randomBytes(32).toString('hex')) and expiresAt to invitations; look invitations up by token instead of id; send the token by email; mark accepted inside the same transaction as team_members insert. Add email verification (or at least require the invite token to prove control of the inbox).

Paste into Cursor / Claude Code
In lib/db/schema.ts add `token: text('token').notNull().unique()` and `expiresAt: timestamp('expires_at').notNull()` to the invitations table and generate a migration. In app/(login)/actions.ts inviteTeamMember, generate token with crypto.randomBytes(32).toString('hex') and expiresAt = now + 7 days; in signUp, replace the lookup by parseInt(inviteId) with a lookup by token (rename the hidden field to inviteToken in app/(login)/login.tsx) and also require expiresAt > now. Wire an email send with the link /sign-up?inviteToken=... Reason: invitation ids are guessable serial integers and emails are unverified, so pending invites can be hijacked.
What's wrong

The route is unauthenticated (middleware excludes /api). It reads client_reference_id from the Stripe session, loads that user, writes subscription data to the user's team, and calls setSession(user[0]) — issuing a fresh session cookie for that user to whoever made the request. It never checks that the request comes from the same logged-in user, nor that session.status === 'complete' / payment_status. Checkout session ids appear in the Stripe-hosted checkout URL, browser history, referrers and logs.

Impact

Anyone who obtains a cs_... id (shared link, shoulder-surfed URL, log access) can log in as that customer indefinitely (the sliding session never expires, see F13). A stale or incomplete session could also be replayed to (re)write team billing state.

Fix

Do not call setSession here; require the current session user (getUser()) to equal client_reference_id, or simply skip the login and only update the team. Verify session.status === 'complete' before writing. Prefer making the webhook (checkout.session.completed) the source of truth and let this route only redirect.

Paste into Cursor / Claude Code
In app/api/stripe/checkout/route.ts: (1) remove the `await setSession(user[0])` call; (2) after retrieving the session, `if (session.status !== 'complete') throw new Error('Checkout not complete')`; (3) call getUser() from lib/db/queries and, if a user is logged in, require String(user.id) === session.client_reference_id, otherwise redirect to /sign-in. Reason: the route currently mints a session cookie for the user referenced by any checkout session id presented to it, turning a leaked URL into an account login.
What's wrong

teams.stripeCustomerId is only written in the success redirect route (app/api/stripe/checkout/route.ts line 82). The webhook (app/api/stripe/webhook/route.ts lines 24-25) handles only customer.subscription.updated/deleted and handleSubscriptionChange looks the team up by stripeCustomerId (line 124). For a first-time subscriber, the customer is created by Stripe during checkout, so if the user closes the tab, loses connectivity, or the redirect route throws (e.g. product not expanded), stripeCustomerId is never set and every later webhook logs 'Team not found' and returns. checkout.session.completed and customer.subscription.created are unhandled.

Impact

Customers are charged (after trial) with no subscription reflected in the app and no way for the webhook to ever reconcile. Support tickets and refunds.

Fix

Handle checkout.session.completed in the webhook: read session.client_reference_id (or put teamId in session.metadata), retrieve the subscription, and upsert stripeCustomerId/subscription fields on the team. Make the redirect route idempotent and purely cosmetic. Also handle customer.subscription.created.

Paste into Cursor / Claude Code
In lib/payments/stripe.ts createCheckoutSession, add `metadata: { teamId: String(team.id) }` to the checkout session. In app/api/stripe/webhook/route.ts add a case for 'checkout.session.completed' that reads event.data.object.metadata.teamId, retrieves the subscription with items.data.price.product expanded, and calls updateTeamSubscription with stripeCustomerId, stripeSubscriptionId, stripeProductId, planName and status (extend updateTeamSubscription in lib/db/queries.ts to accept stripeCustomerId). Also add 'customer.subscription.created' to the existing handleSubscriptionChange case. Reason: today the DB is only updated by the success-page redirect, so a closed tab means a paying customer with no plan and webhooks can't find the team.

Quick wins

  • · Change removeTeamMemberSchema to z.coerce.number() — one-line fix that makes the Remove button work (F2).
  • · Strip passwordHash from getUser()/api responses before anything else ships (F4).
  • · Add `if (session.status !== 'complete')` and remove setSession() in app/api/stripe/checkout/route.ts (F6).
  • · Add 'checkout.session.completed' and 'customer.subscription.created' cases to the webhook (F7).
  • · Fail fast on missing AUTH_SECRET / STRIPE_* env vars at module load (F13).
  • · Stop returning password fields from server actions (F19).
  • · Pin Next.js to a stable release and drop experimental flags (F16).
  • · Add indexes on team_members(user_id), team_members(team_id), activity_logs(user_id, timestamp), invitations(email, team_id) before tables grow.

What's already good

  • · Stripe webhook signature is verified with the raw body and rejects bad signatures with 400 (app/api/stripe/webhook/route.ts).
  • · Passwords hashed with bcrypt (cost 10); session cookies are httpOnly, secure, sameSite=lax.
  • · Every server action goes through a zod schema and the validatedActionWithUser wrapper, so unauthenticated calls are rejected uniformly.
  • · Soft-deleted users are excluded in getUser() via isNull(deletedAt), so a deleted account's token stops working immediately.
  • · Sign-in returns the same error for unknown email and wrong password.
  • · No secrets committed; .env is gitignored and .env.example uses placeholders.
  • · Team-scoped deletes include teamId in the WHERE clause, preventing cross-team IDOR on removeTeamMember.

Do this first

  1. Fix authorization: add owner checks to inviteTeamMember/removeTeamMember, fix z.coerce on memberId, and set the invited role correctly in signUp (F1, F2, F3, F9).
  2. Fix billing provisioning: handle checkout.session.completed in the webhook with teamId metadata, expand/retrieve product names, cover all subscription statuses, stop double checkouts, and remove the login-by-session_id behavior (F6, F7, F8, F11).
  3. Stop leaking passwordHash and Stripe IDs to the client via /api/user, /api/team and the root layout SWR fallback (F4).
  4. Replace integer inviteId with random tokens + expiry and actually send invitation emails; add email verification (F5).
  5. Harden auth plumbing: env validation, rate limiting on sign-in/sign-up, session revocation on password change, no password echo (F13, F14, F19).
  6. Make deleteAccount cancel or block on active subscriptions and run DB writes in transactions (F10, F9).
  7. Pin Next.js stable, configure the Postgres pool, and make the pricing page use configured price IDs so a live Stripe account works (F16, F17, F18, F20).
VibeAudit badge
Add the badge to your README
[![VibeAudit](https://vibeaudit.sh/api/badge/fx04saasstart)](https://vibeaudit.sh/a/fx04saasstart)

Fixed things? Re-audit.

Run a new scan on the updated repo. Use a 5-pack key or pay per audit.

Public repo URL, no signup. Private repo? Sign in with GitHub and pick it — read-only, nothing stored except the report.