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
boxyhq/saas-starter-kit
70–89 fix first
<70 not ready
BoxyHQ SaaS Starter Kit: a Next.js 15 (pages router) multi-tenant B2B app with NextAuth, Prisma/Postgres, embedded SAML Jackson (SSO + SCIM), Stripe billing, Svix webhooks and Retraced audit logs. The core structure is solid (zod validation, RBAC helper, hashed API keys, lockout, verified Stripe webhooks), but the enterprise-auth surface has real cross-tenant holes: SAML/OAuth logins link to any existing account by email with no tenant check, the SSO DELETE route lets one team delete another team's connections, the DSync PATCH route lets a body-supplied directoryId override the authorized one, and the payments routes skip RBAC entirely. Fix the account-linking takeover first, then the two cross-tenant IDORs and the billing authorization gap, then the broken HTTPS logout.
320 files reviewed · claude-fable-5-1 · deep audit
Findings (20)
BoxyHQSAMLProvider (line 136), GitHub (114) and Google (124) all set allowDangerousEmailAccountLinking: true. In the signIn callback (lines 295, 344-355) an existing user found by email is simply signed in and linked; profile.requested.tenant is only consulted for brand-new users in linkToTeam (431). Team.domain is never checked. Any team admin can configure their own IdP (the e2e tests use mocksaml which lets you type any username) and have it assert an email of a user in another team; NextAuth finds that user by email, links the SAML account and issues a session as them. The boxyhq-idp credentials provider (143-215) has the same behaviour via linkAccount/existingUser.
Anyone who can create an SSO connection for their own team (ADMIN or OWNER, and signup is open) can log in as any user in the system, including owners of other tenants, and read/modify their teams, billing and API keys.
Disable dangerous linking for the SAML and idp providers and gate SAML sign-in on tenant membership or verified domain. In signIn: if account.provider is 'boxyhq-saml' or 'boxyhq-idp', load the team from profile.requested.tenant; if existingUser exists, require that existingUser is already a TeamMember of that team OR the email domain equals team.domain; otherwise return '/auth/login?error=sso-not-allowed'. Only link the SAML Account after that check passes. For GitHub/Google keep linking only if the provider reports email_verified.
In lib/nextAuth.ts, harden SSO account linking. 1) Set allowDangerousEmailAccountLinking: false on BoxyHQSAMLProvider. 2) In callbacks.signIn, when account.provider === 'boxyhq-saml' or account.provider === 'boxyhq-idp', resolve the tenant team via getTeam({ id: profile.requested.tenant }) (for idp the profile is on `user`), and if an existingUser is found by email, only allow the login if (a) prisma.teamMember exists for { teamId: team.id, userId: existingUser.id } or (b) the email domain equals team.domain. If neither holds, return '/auth/login?error=sso-not-allowed' and do NOT call linkAccount. Add the 'sso-not-allowed' key to locales/en/common.json. Reason: currently any team admin can point their IdP at an arbitrary email and take over that user's account across tenants.handleDELETE calls throwIfNoAccessToConnection with extractClientId(req) (req.query.clientID). lib/guards/team-sso.ts returns early when clientId is null (line 29-31). Then sso.deleteConnection({ ...req.query }) is called with the untouched query. Jackson's deleteConnections accepts either {clientID, clientSecret} or {tenant, product}, so DELETE /api/teams/<my-slug>/sso?tenant=<victim-team-id>&product=boxyhq skips the guard and deletes the victim's connections. Team IDs are obtainable via /api/auth/sso/verify (returns teamId for any email) and the invitation endpoint.
An ADMIN of any team can wipe SSO for other tenants, locking their users out of SSO login.
Never pass client-controlled tenant/product. Require clientID and delete by { clientID, clientSecret } after the guard, or delete by { tenant: teamMember.teamId, product: env.jackson.productId } only.
In pages/api/teams/[slug]/sso.ts handleDELETE, stop spreading req.query into sso.deleteConnection. Read clientID from req.query, throw ApiError(400) if missing, keep the throwIfNoAccessToConnection call, then look up the connection with sso.getConnections({ clientID }) and call sso.deleteConnection({ clientID, clientSecret: connections[0].clientSecret }). Also change the sendAudit crud from 'c' to 'd'. Reason: the current code lets a caller pass tenant=<other team id>&product=boxyhq (no clientID, so the guard is skipped) and delete another tenant's SSO connections.handlePATCH authorizes req.query.directoryId (lines 71-74) but then builds body = { ...req.query, ...req.body } so a body field directoryId replaces the checked one, and dsync.updateConnection(body) uses params.directoryId (lib/jackson/dsync/embed.ts line 53, hosted.ts line 62). Jackson's directories.update does not verify tenant against the caller. The PATCH also checks 'read' instead of 'update' permission (line 69).
A team admin can rename, deactivate or otherwise modify another tenant's SCIM directory, breaking their user provisioning.
Build the update payload from the authorized query id only: const body = { ...req.body, directoryId: req.query.directoryId }. Whitelist updatable fields (name, deactivated, log_webhook_events). Use 'update' action.
In pages/api/teams/[slug]/dsync/[directoryId].ts handlePATCH: change throwIfNotAllowed(teamMember, 'team_dsync', 'read') to 'update' (and add 'update' handling if needed), and replace `const body = { ...req.query, ...req.body }` with a validated object that only takes name, deactivated and log_webhook_events from req.body and sets directoryId from req.query.directoryId AFTER the spread so the body cannot override it. Reason: currently a body-supplied directoryId bypasses throwIfNoAccessToDirectory and allows editing another team's directory.create-portal-link.ts (line 32-34), create-checkout-session.ts (line 38-40) and products.ts (line 35-39) call throwIfNoTeamAccess but never throwIfNotAllowed(teamMember, 'team_payments', ...). lib/permissions.ts gives team_payments only to OWNER (ADMIN and MEMBER have none). The UI hides the tab (TeamTab.tsx line 80) but the API is open. getStripeCustomerId (lib/stripe.ts line 21-28) also creates a Stripe customer using the *requesting member's* email/name and writes billingId to the team as a side effect of a GET.
Any member can cancel the team's subscription or change the card in the Stripe portal, create new subscriptions, and cause the team's Stripe customer record to be created with a random member's email.
Add throwIfNotAllowed(teamMember, 'team_payments', 'update') to create-portal-link and create-checkout-session, and 'read' to products. Only create the Stripe customer in the checkout/portal flows (owner-only), not on GET products.
In pages/api/teams/[slug]/payments/create-portal-link.ts, create-checkout-session.ts and products.ts, after `const teamMember = await throwIfNoTeamAccess(req, res)` add `throwIfNotAllowed(teamMember, 'team_payments', 'update')` (portal + checkout) and `throwIfNotAllowed(teamMember, 'team_payments', 'read')` (products), importing throwIfNotAllowed from 'models/user'. In products.ts, do not call getStripeCustomerId; instead read teamMember.team.billingId and return an empty subscriptions array if it is null. Reason: MEMBER/ADMIN have no team_payments permission in lib/permissions.ts but the API currently lets them manage billing.
The handler hardcodes Set-Cookie 'next-auth.session-token=; ...'. lib/nextAuth.ts line 43-44 shows that on https APP_URLs the cookie is named '__Secure-next-auth.session-token'. With NEXTAUTH_SESSION_STRATEGY=jwt (the default in lib/env.ts line 21) nothing is deleted server-side, so the JWT cookie survives and the user is still logged in; hooks/useCustomSignout.ts just pushes to /auth/login, which redirects back to /dashboard because status is 'authenticated'. Database strategy works only because the DB row is deleted.
In production every 'Sign out' click silently fails; sessions persist 14 days on shared machines.
Use sessionTokenCookieName and cookies-next deleteCookie (with the same secure/path attributes), or call next-auth's signOut on the client after the custom endpoint.
In pages/api/auth/custom-signout.ts replace the hardcoded res.setHeader('Set-Cookie', 'next-auth.session-token=; ...') with `deleteCookie(sessionTokenCookieName, { req, res, path: '/', secure: env.appUrl.startsWith('https://'), httpOnly: true, sameSite: 'lax' })` imported from 'cookies-next'. In hooks/useCustomSignout.ts, after the fetch succeeds call `signOut({ callbackUrl: '/auth/login' })` from 'next-auth/react' instead of router.push so the JWT cookie is cleared by NextAuth as well. Reason: on HTTPS the cookie is prefixed __Secure- so the current header clears a cookie that does not exist and logout fails with the default JWT strategy.handleDELETE just calls deleteTeam({ id }). Prisma cascades TeamMember/Invitation/ApiKey, but Subscription rows (keyed by customerId) are untouched, no stripe.subscriptions.cancel is issued, and Jackson SSO connections, SCIM directories (whose SCIM endpoint keeps accepting provisioning) and the Svix application remain. The repo's own delete-team.js script (lines 231-251) does all of these steps and refuses to delete while subscriptions are active; the API route does none of them. Any ADMIN can trigger this (team: '*').
Customers delete a team and keep being charged; dangling SCIM endpoints and SSO connections remain live for a tenant that no longer exists.
Mirror delete-team.js: refuse deletion if an active subscription exists (or cancel it via Stripe), delete Jackson SSO and DSync connections for the tenant, delete the Svix app, delete Subscription rows, then the team — inside a try/transaction.
In pages/api/teams/[slug]/index.ts handleDELETE, before deleteTeam: (1) if user.team.billingId, query prisma.subscription for active rows with endDate > now and throw ApiError(400, 'Cancel active subscriptions before deleting the team') (or cancel them with stripe.subscriptions.cancel); (2) call ssoManager().deleteConnection({ tenant: user.team.id, product: env.jackson.productId }); (3) list and delete DSync directories via dsyncManager(); (4) delete the Svix application via svix.application.delete(team.id) guarded by feature flag; (5) prisma.subscription.deleteMany({ where: { customerId: billingId } }); then deleteTeam. Model this on delete-team.js. Reason: the API currently leaves Stripe billing running and SSO/SCIM live after the team is gone.NEXTAUTH_SECRET=rZTFtfNuSMajLnfFrWT2PZ3lX8WZv7W/Xs2H8hkEY6g= is committed. README step 4 is `cp .env.example .env`; rotating the secret is buried under 'Feature configuration'. lib/env.ts line 20 passes whatever is set straight to NextAuth with no validation. With the default JWT strategy the secret signs session tokens.
Any self-hosted deployment that kept the example value lets anyone mint a valid session JWT for any user id (public value, published on GitHub).
Leave the value blank in .env.example and fail fast at startup if NEXTAUTH_SECRET is missing or equals the known example value.
Edit .env.example to set NEXTAUTH_SECRET= (empty) with a comment to generate one via `openssl rand -base64 32`. In lib/env.ts (or instrumentation.ts register()), add a startup check: if process.env.NODE_ENV === 'production' and (!process.env.NEXTAUTH_SECRET || process.env.NEXTAUTH_SECRET === 'rZTFtfNuSMajLnfFrWT2PZ3lX8WZv7W/Xs2H8hkEY6g=' || process.env.NEXTAUTH_SECRET.length < 32) throw an Error explaining the secret must be set. Reason: the committed example secret is public and signs JWT sessions.
Quick wins
- · Add `return` after the 404 responses in pages/api/oauth/authorize.ts and pages/api/scim/v2.0/[...directory].ts.
- · Blank out NEXTAUTH_SECRET in .env.example and fail startup if it is unset in production.
- · Add throwIfNotAllowed(teamMember, 'team_payments', ...) to the three payments routes (three lines).
- · Fix the sendAudit crud value in sso.ts handleDELETE ('c' → 'd') and the 'read' action used for dsync PATCH / 'create' for sso PATCH.
- · Return `res.status(400)` instead of bare `return` when the Stripe signature is missing.
- · Use sessionTokenCookieName + deleteCookie in custom-signout.ts.
- · Make forgot-password and resend-email-token respond 200 regardless of whether the user exists.
- · Change the build script to `prisma generate && next build` and run `prisma migrate deploy` at release.
What's already good
- · Consistent zod validation (validateWithSchema) on nearly every API input, with unknown keys stripped so mass assignment is not possible on user/team updates.
- · Central RBAC (lib/permissions.ts + throwIfNotAllowed) is applied to most team routes, and lib/rbac.ts encodes sensible role-change rules.
- · API keys are stored as SHA-256 hashes and only shown once; passwords use bcrypt cost 12.
- · Stripe webhook verifies signatures against the raw body with bodyParser disabled.
- · Session deletion checks ownership; dsync/SSO guards check tenant on clientID/directoryId; invitation acceptance verifies email/domain.
- · Account lockout and optional reCAPTCHA exist for credentials login.
- · Good operational scaffolding: health endpoint, Sentry, OTEL metrics hooks, Slack signup alerts, a careful delete-team.js script, and a real Playwright e2e suite covering SSO/DSync/members.
Do this first
- Fix the SAML/IdP email-linking takeover (F1): disable dangerous linking for SAML and require tenant membership or verified domain before signing an existing user in via SSO.
- Close the two cross-tenant IDORs: stop spreading req.query into sso.deleteConnection (F2) and stop letting the body override directoryId in dsync PATCH (F3).
- Add team_payments RBAC to the payments routes and stop creating Stripe customers on GET (F4); block duplicate subscriptions (F11) and make the webhook idempotent (F10).
- Repair logout on HTTPS (F5), set HttpOnly/SameSite on the DB session cookie (F9), and invalidate JWT sessions on password change (F8).
- Make team deletion clean up Stripe/SSO/DSync/Svix or refuse while subscriptions are active (F6).
- Rotate/validate NEXTAUTH_SECRET (F7), add rate limiting and generic responses on auth endpoints (F12, F13), and gate email changes behind verification (F16).
- Tidy correctness issues: link-invite emailVerified and admin→owner invites (F14), ownerless-team paths and invitation revocation (F15), 404/500 handling (F18), avatar storage (F17), build script (F19).
Fixed things? Re-audit.
Run a new scan on the updated repo. Use a 5-pack key or pay per audit.