Supabase's own documentation puts the biggest risk in one line: "A table in an exposed schema without RLS is readable and writable by any role with a grant on it." In a Next.js and Supabase SaaS, the browser talks to your database through Supabase's API, so Row Level Security is not a hardening step for later. It is the permission system.
This guide covers how we would structure a SaaS MVP on Next.js and Supabase, with Stripe for billing: what runs where, where the auth checks belong, how to model teams, why the webhook is the billing record, and the setup jobs that tend to surface after launch. Every limit and default below comes from the Supabase, Next.js and Stripe documentation, read on 28 October 2026. Our bias: we build applications in Next.js.
What runs where in a Next.js and Supabase SaaS
The stack is small, but each piece has a narrow job. The common security mistakes come from code doing a job that belongs to a different layer.
| Layer | Its job | Not its job |
|---|---|---|
| Proxy (proxy.ts, middleware.ts before Next.js 16) | Refresh the Supabase session cookie, redirect signed-out users | The only authorisation check |
| Server Actions and Route Handlers | Mutations, each with its own auth check | Assuming the button was hidden so nobody can call it |
| Postgres with Row Level Security | Decide which rows each user and team can see and change | Being optional because the UI filters data |
| Stripe and the webhook route | Charge cards, own subscription state, notify your app | Being queried live on every page load |
| A subscriptions table in your database | The app's copy of who has paid for what | Being written to by anything except the webhook |
Source: Supabase server-side auth and RLS docs, Next.js authentication guide (updated 2026-09-16), Stripe subscription webhooks docs
Postgres is the boundary: it should refuse a query for another customer's rows even when a Server Action forgets a check. Stripe is the billing record, copied into your database by webhooks so no page load waits on Stripe's API. Our post on The MVP Tech Stack We Recommend in 2026 covers vendor choice and cost; this page is about wiring them together.
Auth: cookies, the server check and the proxy trap
Supabase's Next.js setup uses `@supabase/ssr` and two clients: a browser singleton, and a server client created per request because it needs that request's cookies.
The proxy refreshes the session. The file is `proxy.ts` in Next.js 16 and `middleware.ts` in 15 and earlier; get it wrong and it never runs, so sessions never refresh. Refresh in one place only, because refresh tokens are generally single-use and two refreshers can sign people out.
The server check uses `getClaims()`. Supabase says never to trust `getSession()` in server code, because it reads the cookie without revalidating it. `getClaims()` verifies the token's signature on every call.
The proxy is not the gate. The Next.js authentication guide calls proxy checks "optimistic", puts real checks in a Data Access Layer close to the data, and says to treat Server Actions and Route Handlers like public API endpoints. CVE-2025-29927, published on 21 March 2025 with a CVSS score of 9.1, let attackers bypass authorisation in middleware on self-hosted Next.js before 15.2.3, 14.2.25, 13.5.9 and 12.3.5. Apps whose only check sat there were open.
Email will stop you before code does. Supabase's built-in sender is "not meant for production use": it delivers only to your project's team, 2 messages an hour. Custom SMTP starts at 30 an hour, adjustable. Confirmations, magic links and resets share that budget, so raise it before launch.
Teams and Row Level Security
B2B SaaS sells to companies, so the data model needs an organisation from day one. Retrofitting it means rewriting every policy.
The shape we would start with. An `organisations` table, a `memberships` table linking users to organisations with a role, and `organisation_id` on every customer table. Each policy asks one question: is this user a member of the organisation that owns the row?
Write policies the fast way. Supabase lists three high-impact rules: index every column a policy filters on; write `(select auth.uid())` so Postgres evaluates it once per statement, not per row; and name the role with `to authenticated`. Its performance guide also advises against joins in policies: fetch the user's organisation IDs into a set and test with `in` or `any`.
Know what bypasses it. A secret key uses the `service_role` role, which has `bypassrls`. Webhooks and admin jobs use it; the browser never sees it. If a user's access token is attached, though, the request runs under that user's policies.
Revoke grants, do not just add policies. Adding policies does not remove default grants to `anon` and `authenticated`. With RLS on and no policies, nothing is reachable with a publishable key, a safe default for unfinished tables.
Billing: the webhook is the record
Stripe says subscriptions need a webhook destination "because most subscription activity happens asynchronously".
| Event or status | When it happens | What the app should do |
|---|---|---|
| `customer.subscription.created` | Subscription starts; status may be `incomplete` | Store it, grant nothing yet if incomplete |
| `invoice.paid` | An invoice is paid | Retrieve the subscription, confirm `active`, extend access |
| `invoice.payment_failed` | A renewal charge fails | Email the customer, show a banner, keep access while retries run |
| `customer.subscription.trial_will_end` | 3 days before a trial ends | Check a payment method exists, warn the customer |
| `customer.subscription.deleted` | Subscription ends | Revoke paid features |
| `incomplete` | First payment not completed | Customer has 23 hours to pay, then `incomplete_expired` |
| `past_due` | Latest invoice failed or was not attempted | Ask for new card details |
| `canceled` or `unpaid` | Retries exhausted, per your settings | Revoke access |
Source: Stripe, Using webhooks with subscriptions, read 2026-10-28
Make the handler idempotent. Stripe can send an event twice and does not guarantee order. Log event IDs with a unique constraint and skip repeats. Do not order by `created`, since events can share a second; fetch the current subscription and write that.
Verify, then answer fast. Signature checks need the raw body, so read it as text first, and return a 2xx before slow work. Stripe retries for up to three days in live mode, and failing `invoice.created` can delay finalisation by up to 72 hours.
Let Stripe host what it can. The customer portal covers card updates, plan changes, cancellation and invoices, though not inside an iframe. Smart Retries' recommended default is 8 tries within 2 weeks.
The parts people underestimate
Each is invisible in a demo and expensive the first time it fails.
| Part | What the docs say | What to do in week one |
|---|---|---|
| Database connections from serverless functions | Transaction mode on port 6543 is for "serverless and edge functions"; prepared statements are not supported there | Use the pooler URL, one connection per client, `prepare: false` |
| Schema changes | Editing production in the SQL or Table Editor bypasses migration history and breaks `db push` | Keep every change in `supabase/migrations`, deploy with `supabase db push` from CI |
| Backups | Pro keeps 7 days of daily backups, Free none; Storage objects are not included | Pro from day one; back up Storage separately; PITR is about $100 a month for 7 days |
| Free-tier pausing | Free projects pause after 1 week of inactivity | No paying customer on a Free project |
| Auth rate limits | Sign-ups and sign-ins: 30 per 5 minutes per IP; token endpoint: 150 per 5 minutes per IP | Raise limits before a launch or a single-office customer onboards together |
Source: Supabase docs on connecting to Postgres, database migrations, backups, auth rate limits and pricing, read 2026-10-28
The rate limits are counted per IP address, so 40 staff accepting invitations from one office network can hit 30 requests in 5 minutes. Point-in-time recovery costs $0.137 an hour and needs at least the Small compute add-on.
The build order we would follow
This is the order we would use, not a record of past projects.
Week one. Supabase Pro, migrations in git, organisations and memberships with RLS on every table, `getClaims()` in a Data Access Layer, custom SMTP and error tracking.
Next, the core loop. The workflow a customer pays for, every Server Action calling the Data Access Layer.
Then billing. Checkout, the portal, a verified webhook with an events table, and a subscriptions table only the webhook writes, tested with the Stripe CLI.
Before launch. Sign in as organisation A and try to read organisation B's rows through the API. If anything returns, a policy is missing.
Our MVP Spec Template post has the one-page brief we would ask for before quoting this work, and Supabase vs Firebase for a B2B SaaS MVP explains why we lean to Postgres for team-based data.
What this means for founders planning a Next.js and Supabase SaaS
The framework and database are the easy decisions. The time goes into the joins: the auth check in three places, the team policies, and a webhook that stays correct when events arrive late, twice or out of order. Budget for those as features.
If you are comparing quotes, ask each team how they handle the five setup jobs in the table above. A team that names RLS performance rules, webhook idempotency and Storage backups without prompting has built this before. Our web app development service page explains how we scope a Next.js build like this one.


