VibeAudit

VibeAudit · whole-repo audit, findings verified against the code, fix prompt per finding. Free quick scan, $19 deep audit. Scan your own repo →

DEEP AUDIT · 2026-09-04

makerkit/nextjs-saas-starter-kit-lite

Download .md
70/100
FIX FIRST
90+ ship it
70–89 fix first
<70 not ready

Makerkit Next.js 16 + Supabase SaaS starter (lite): marketing pages, Supabase auth (password/OAuth/MFA), a personal `accounts` table with RLS, an avatar bucket, and an account-deletion server action. The foundation is sound: RLS is correct, the service-role key stays server-only, server actions require auth by default, and there is no billing or user-content surface to leak. The things that will bite are in the auth callback and account-deletion paths: `/auth/callback?next=` is an unvalidated open redirect, and deleting an account leaves an orphaned `public.accounts` row (no FK to `auth.users`) whose unique email blocks the same person from ever signing up again, while the delete call's error result is ignored so failures are reported as success. Fix those three before launch; the rest is polish.

Next.js 16.3 (App Router, cacheComponents/PPR, proxy.ts middleware)React 19TypeScript 7Supabase (Auth, Postgres RLS, Storage) via @supabase/ssrTurborepo + pnpm monorepoTailwind CSS v4 + shadcn/Base UInext-intlTanStack QueryZod v4react-hook-formPino loggingCloudflare Turnstile captcha (optional, not configured)Playwright e2eGitHub Actions

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

Findings (14)

What's wrong

`exchangeCodeForSession` reads `searchParams.get('next')` and, when there is no `code` and no `error`, returns it unchanged as `nextPath` (line 181). `apps/web/app/auth/callback/route.ts` line 16 then calls `redirect(nextPath)`. Next.js `redirect()` accepts absolute URLs, so `GET /auth/callback?next=https://evil.example` on your domain 307s to the attacker's site with no auth step in between. The proxy only bounces logged-in users away from `/auth/*`, so any logged-out visitor is exposed. The same pattern exists in `apps/web/app/update-password/page.tsx` line 47, where the `callback` search param becomes `redirectTo` for the update-password form.

Impact

Phishing links that start on your trusted domain and land on an attacker page (classic credential-harvest flow: 'your session expired, sign in again'). Also breaks OAuth allow-listing assumptions since the app itself becomes the redirector.

Fix

Only accept same-origin relative paths. Add a helper and use it for `next` in both `exchangeCodeForSession` and `verifyTokenHash`, and for `callback` in update-password: function safePath(value: string | null, fallback: string) { if (!value) return fallback; if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) return fallback; return value; } const nextUrl = safePath(nextUrlPathFromParams, params.redirectPath);

Paste into Cursor / Claude Code
In packages/supabase/src/auth-callback.service.ts, the `next` query parameter is used as a redirect target without validation (line 136-139 in exchangeCodeForSession, and the `callbackNextPath` value in verifyTokenHash around line 66-73). Add a module-level helper `safeRelativePath(value: string | null, fallback: string): string` that returns `fallback` unless `value` starts with a single '/' and does not start with '//' or '/\\'. Use it so `nextUrl` in exchangeCodeForSession and `nextPath` in verifyTokenHash can never be an absolute or protocol-relative URL. Then in apps/web/app/update-password/page.tsx, apply the same validation to the `callback` search param before passing it as `redirectTo` to UpdatePasswordForm (fall back to pathsConfig.app.home). Reason: `redirect()` accepts absolute URLs, so `/auth/callback?next=https://evil.example` is currently an open redirect.
What's wrong

`accounts.id` is `uuid unique not null default uuid_generate_v4()` with no `references auth.users(id) on delete cascade`. `kit.new_user_created_setup` inserts a row with `id = new.id` and `email = new.email` (line 253-260), and `email` is `unique` (line 94). `deletePersonalAccountAction` deletes only the auth user via `adminClient.auth.admin.deleteUser(userId)`. The `accounts` row (and the user's public avatar object in the `account_image` bucket) survive. When that person signs up again with the same email, the trigger's insert violates the email unique constraint, the trigger raises, and Supabase user creation fails.

Impact

Anyone who deletes their account can never register again with that email (sign-up returns a database error). Personal data (name, email, avatar) persists after the user asked for deletion, which is a GDPR problem. Orphan rows accumulate with no owner.

Fix

Add a migration: alter table public.accounts add constraint accounts_id_fkey foreign key (id) references auth.users(id) on delete cascade; (Clean up existing orphans first: `delete from public.accounts a where not exists (select 1 from auth.users u where u.id = a.id);`.) In DeletePersonalAccountService also remove the avatar: `await adminClient.storage.from('account_image').remove([...])` for objects whose filename is `${userId}.*` (list the bucket with a `search: userId` filter first).

Paste into Cursor / Claude Code
Create a new Supabase migration in apps/web/supabase/migrations/ that (1) deletes orphaned rows: `delete from public.accounts a where not exists (select 1 from auth.users u where u.id = a.id);` and (2) adds `alter table public.accounts add constraint accounts_id_fkey foreign key (id) references auth.users(id) on delete cascade;`. Then in packages/features/accounts/src/server/services/delete-personal-account.service.ts, before calling `adminClient.auth.admin.deleteUser(userId)`, list objects in the `account_image` bucket matching the user id (`adminClient.storage.from('account_image').list('', { search: userId })`) and remove them with `.remove(paths)`. Reason: accounts.id currently has no FK to auth.users, so deleting the auth user leaves an orphaned accounts row whose unique email prevents the same email from ever signing up again, and the public avatar stays online.
What's wrong

supabase-js admin methods do not throw; they resolve to `{ data, error }`. Line 49 awaits `deleteUser(userId)` and discards the result, so the try/catch on lines 48-60 only catches network-level exceptions. Any Supabase-side failure (invalid service role key, project paused, 4xx/5xx from GoTrue) is swallowed; the code then logs 'User successfully deleted!', the action calls `revalidatePath` and redirects to `/`. Note also that `server-actions.ts` line 57 signs the user out globally *before* attempting the delete, so on failure the user is logged out of every device but the account still exists.

Impact

Users see 'account deleted' when nothing happened. Their data stays, they can log back in, and you have a false audit log entry saying the deletion succeeded.

Fix

const { error } = await params.adminClient.auth.admin.deleteUser(userId); if (error) { logger.error({ ...ctx, error }, 'Encountered an error deleting user'); throw new Error('Error deleting user'); } And in server-actions.ts, move `client.auth.signOut()` to after `deletePersonalAccount` resolves (or drop it: deleting the user invalidates the session anyway).

Paste into Cursor / Claude Code
In packages/features/accounts/src/server/services/delete-personal-account.service.ts, change line 49 to destructure the result: `const { error } = await params.adminClient.auth.admin.deleteUser(userId);` and if `error` is truthy, log it with logger.error and `throw new Error('Error deleting user')`. Keep the existing try/catch for thrown exceptions. Then in packages/features/accounts/src/server/server-actions.ts, move the `await client.auth.signOut();` call (line 57) to after `service.deletePersonalAccount(...)` completes successfully. Reason: supabase-js admin methods return `{ data, error }` instead of throwing, so failed deletions are currently reported as success, and signing out before the delete leaves users locked out of an account that still exists when the delete fails.

Quick wins

  • · Disallow `/home`, `/auth`, `/update-password` in apps/web/app/robots.ts so app and auth screens are not indexed.
  • · Set the Pino logger level from an env var (`process.env.LOG_LEVEL ?? 'info'`) in packages/shared/src/logger/impl/pino.ts; it is hardcoded to 'debug' in production.
  • · Wire `apps/web/instrumentation.ts` onRequestError to a real monitor (Sentry/Baselime) instead of console.error, or you will not know about F6/F9-style failures.
  • · In apps/web/proxy.ts, replace `new URL(pathsConfig.auth.signIn + '?next=' + next)` with URLSearchParams so paths with special characters are encoded.
  • · Remove the dead `x-correlation-id` / `x-action-path` header writes in proxy.ts (request-header mutations do not propagate without `NextResponse.next({ request })`) or implement them properly.
  • · Verify `apps/web/.env.production` contains only NEXT_PUBLIC_* values and that SUPABASE_SERVICE_ROLE_KEY / CAPTCHA_SECRET_TOKEN live only in your host's secret store.
  • · Fix the `data-testidid` typo in packages/ui/src/makerkit/data-table.tsx line 298 before writing e2e tests against it.
  • · Add `additional_redirect_urls` for your production domain in Supabase Auth settings so OAuth `redirectTo` to `/auth/callback` is accepted.

What's already good

  • · RLS on `accounts` is tight: select/update scoped to `auth.uid() = id`, no insert/delete policy for authenticated, default privileges revoked from anon, and a trigger blocks id/email changes.
  • · Service-role key is confined to `server-only` modules (`server-admin-client.ts`, `get-service-role-key.ts`) and never reaches the client bundle.
  • · `enhanceAction` defaults to requiring auth and uses `getClaims()` (signature-verified) rather than trusting the cookie session.
  • · MFA assurance level is enforced consistently in middleware, `requireUser`, and server actions.
  • · Cache Components / PPR discipline is thoughtful: request-bound reads are pushed behind Suspense, root layout stays static, and the reasoning is documented inline.
  • · Config is validated with Zod at boot (app/auth/feature-flags/paths), including a production guard that fails the build on an http NEXT_PUBLIC_SITE_URL.
  • · Storage policy ties object names to the uploader's uuid via a cast that rejects non-uuid filenames.

Do this first

  1. Fix F1 (open redirect): validate `next`/`callback` as same-origin relative paths in auth-callback.service.ts and update-password/page.tsx.
  2. Fix F2 + F3 together: add the `accounts.id → auth.users(id) on delete cascade` FK migration, check the `deleteUser` error result, delete the avatar object, and move signOut after the delete.
  3. Apply the storage bucket size/MIME limits (F4) and exclude /auth/callback and /auth/confirm from the logged-in bounce in proxy.ts (F5).
  4. Fix the user-visible embarrassments: `<DefaultError />` literal (F7), /contact 404s (F8), and make global-error.tsx self-contained (F9).
  5. Enable Turnstile captcha in Supabase and the app (F12), remove the identities-based enumeration check (F11), and harden enhanceRouteHandler status codes before adding API routes (F13).
  6. Add a server-side `requireUserInServerComponent` under /home (F14), hook up error monitoring, and fix robots.ts; then re-run the Playwright suite against a production build.
VibeAudit badge
Add the badge to your README
[![VibeAudit](https://vibeaudit.sh/api/badge/4crkalcdub)](https://vibeaudit.sh/a/4crkalcdub)

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.