Secure Auth & RLS in EliteSaaS: Supabase + Next.js Guide
Step-by-step EliteSaaS guide: setup Supabase Auth, secure sessions with auto-refresh, App Router middleware, and PostgreSQL RLS for multi-tenant Next.js apps.
Introduction
Building a secure multi-tenant SaaS means getting authentication, session handling, and database access control absolutely right. EliteSaaS uses Supabase (PostgreSQL + Auth) and Next.js App Router to deliver production-ready flows: email sign-in, verification, password recovery, secure sessions with token refresh, middleware-based route protection, and PostgreSQL Row-Level Security (RLS) for tenant isolation.
This guide walks through pragmatic, copy‑pasteable patterns you can use in EliteSaaS: Supabase Auth configuration, secure session handling, middleware protection, concrete RLS policies for profiles, organizations, and organization_members, TanStack Query patterns, Zod validation examples, and edge cases like invitation tokens, service-role webhooks, and audit logging. A migration checklist and tests are included so you can deploy with confidence.
(If you need role-hierarchy patterns or compliance details, see the Team Management & RBAC Patterns and Security Deep Dive posts in the EliteSaaS docs.)
1) Supabase Auth: setup & core flows
Quick checklist to configure Supabase Auth for EliteSaaS:
- Enable Email + Password provider in Supabase Auth.
- Configure Email templates (verification, password reset). In EliteSaaS we integrate Resend for consistent branded templates.
- Configure redirect URLs (email confirmations) to your Next.js app (use env var NEXT_PUBLIC_APP_URL).
- Keep
SUPABASE_SERVICE_ROLE_KEYsecret — use only on server (webhooks, migrations, admin tasks).
Client & server clients (examples):
// lib/supabaseClient.ts (browser-safe)
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
// lib/supabaseAdmin.ts (server-only, service role)
import { createClient } from '@supabase/supabase-js';
export const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
);
Email verification & password recovery rely on Supabase auth flows. Use signUp and resetPasswordForEmail on the client and handle redirects server-side to display friendly pages.
Security tip: never expose the service role key to the browser or commit it to source control.
2) Secure session handling & automatic token refresh
Supabase's browser client handles refresh tokens for you, but server endpoints and middleware must validate the incoming token. Recommended patterns:
- Use secure, HttpOnly cookies for server SSR/API access when possible (or forward access_token in Authorization header).
- For client-side interactions, use the Supabase client and
onAuthStateChangeto keep UI in sync. - For server APIs, accept
Authorization: Bearer <access_token>and validate with the admin client.
Server-side token validation example (API route / server handler):
// app/api/protected/route.ts (Next.js App Router handler)
import { supabaseAdmin } from '@/lib/supabaseAdmin';
export async function GET(req: Request) {
const authHeader = req.headers.get('authorization');
const token = authHeader?.split(' ')[1];
if (!token) return new Response('Unauthorized', { status: 401 });
const { data, error } = await supabaseAdmin.auth.getUser(token);
if (error || !data.user) return new Response('Unauthorized', { status: 401 });
// user is valid: continue
return new Response(JSON.stringify({ user: data.user }));
}
This pattern is safe because the admin client performs token validation using Supabase Auth.
3) Middleware-based route protection (Next.js App Router)
Use Next.js Middleware to redirect unauthenticated users away from /app or /admin. Middleware runs at the edge so keep it minimal and use Supabase's REST auth endpoint to validate tokens.
Example middleware.ts (edge-compatible):
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(req: NextRequest) {
const token = req.cookies.get('sb-access-token')?.value || req.headers.get('authorization')?.split(' ')[1];
if (!token) return NextResponse.redirect(new URL('/login', req.url));
const resp = await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/user`, {
headers: {
Authorization: `Bearer ${token}`,
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
});
if (!resp.ok) return NextResponse.redirect(new URL('/login', req.url));
return NextResponse.next();
}
export const config = { matcher: ['/app/:path*', '/admin/:path*'] };
Notes:
- Keep middleware lightweight and avoid heavy DB access from the edge.
- For permission or subscription checks, prefer server-side checks in page loaders (server components) where you can use the admin client.
4) Row-Level Security (RLS): practical policies
RLS is the single most important control for multi-tenant isolation. Below are compact, battle-tested policies you can adapt.
Prerequisites: enable RLS on each table used by tenants.
Profiles: users can only read/update their own profile
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Profiles: select own" ON public.profiles
FOR SELECT USING (id = auth.uid());
CREATE POLICY "Profiles: update own" ON public.profiles
FOR UPDATE USING (id = auth.uid()) WITH CHECK (id = auth.uid());
Organizations: members can view org rows
ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Orgs: select if member" ON public.organizations
FOR SELECT USING (
EXISTS (
SELECT 1 FROM public.organization_members om
WHERE om.organization_id = organizations.id AND om.user_id = auth.uid()
)
);
Organization members: allow read for org members; updates by owners/admins
ALTER TABLE public.organization_members ENABLE ROW LEVEL SECURITY;
CREATE POLICY "OrgMembers: select if related" ON public.organization_members
FOR SELECT USING (
EXISTS (
SELECT 1 FROM public.organization_members om
WHERE om.organization_id = organization_members.organization_id
AND om.user_id = auth.uid()
)
);
CREATE POLICY "OrgMembers: update by admin/owner" ON public.organization_members
FOR UPDATE USING (
EXISTS (
SELECT 1 FROM public.organization_members om
WHERE om.organization_id = organization_members.organization_id
AND om.user_id = auth.uid()
AND om.role IN ('owner','admin')
)
);
Important: complex checks (prevent removing last owner) are best implemented as a server-side check or stored procedure run with service role privileges. RLS alone can be bypassed by a service-role update if misused; therefore constrain service-role usage strictly to server endpoints with signature verification.
Example function to prevent removing last owner (run as service role in webhook/endpoint):
CREATE OR REPLACE FUNCTION ensure_not_remove_last_owner(org uuid, removing_user uuid)
RETURNS void LANGUAGE plpgsql AS $$
DECLARE owner_count int;
BEGIN
SELECT COUNT(*) INTO owner_count
FROM organization_members
WHERE organization_id = org AND role = 'owner' AND deleted_at IS NULL;
IF owner_count <= 1 THEN
RAISE EXCEPTION 'Cannot remove last owner of organization %', org;
END IF;
END; $$;
Call this function from your server-side code before performing a destructive role change.
5) Client patterns: TanStack Query + Zod validation
Use TanStack Query for server-state and optimistic updates. Validate inbound API payloads with Zod on server endpoints.
Zod invite acceptance schema example:
import { z } from 'zod';
export const acceptInviteSchema = z.object({
token: z.string().min(10),
});
TanStack Query patterns (fetch orgs and accept invite mutation):
// hooks/useOrganizations.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/lib/supabaseClient';
export function useOrganizations() {
return useQuery(['organizations'], async () => {
const { data, error } = await supabase.from('organizations').select('*');
if (error) throw error;
return data;
}, { staleTime: 60_000 });
}
export function useAcceptInvite() {
const qc = useQueryClient();
return useMutation(async (token: string) => {
const res = await fetch('/api/teams/accept-invite', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
if (!res.ok) throw new Error('Invite acceptance failed');
return res.json();
}, {
onSuccess: () => qc.invalidateQueries(['organizations'])
});
}
Use optimistic updates when changing membership lists to keep the UI snappy. Always invalidate queries on server-confirmed results.
6) Edge cases & operational patterns
Invitation tokens
- Generate cryptographically-random tokens server-side (e.g.
crypto.randomBytes(32).toString('base64url')). - Store only a hashed token in DB (SHA256 or better). On acceptance, hash provided token and find the invitation.
- Set expiration (e.g.
expires_at) and 7-day default lifetime.
Service role client for webhooks
- Use the service role client only in server endpoints that verify webhook signatures (Stripe Webhook signature or Supabase
verifySig). - Example: handle Stripe
invoice.payment_failed-> usesupabaseAdminto update subscription rows and fire internal jobs.
Audit logging patterns
- Keep an
audit_logstable and insert audit records from your server-side service functions when changing permissions. - Optionally add DB triggers for certain tables, but prefer server-side logs so you can include the acting user id and request metadata.
Example audit table:
CREATE TABLE audit_logs (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid,
action text NOT NULL,
resource text,
meta jsonb,
created_at timestamptz DEFAULT now()
);
Insert audit entries from your role-change endpoint using supabaseAdmin.
7) Migration checklist & tests to validate enforcement
Migration checklist
- Add indexes on
organization_members(user_id, organization_id)and on token hashes. - Enable RLS on
profiles,organizations,organization_members,organization_invitations,subscriptions. - Add SELECT/INSERT/UPDATE policies per table.
- Create stored procedures for last-owner checks and other atomic constraints.
- Create
audit_logstable and add server-side inserts from admin endpoints. - Add tests (see below).
Tests to run (integration)
- Auth flow: sign-up -> verify email -> login -> ensure session present.
- RLS: authenticated user fetches only their own profile; attempt to fetch another profile returns empty/error.
- Organization access: non-member cannot
SELECTorganization row; member can. - Invitation: create invitation, accept with token, confirm membership exists.
- Last-owner: attempt to remove the only owner should fail (assert error response).
Simple Jest + Supabase test skeleton:
import { createClient } from '@supabase/supabase-js';
const anon = createClient(process.env.SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);
test('non-member cannot read organization', async () => {
const { data, error } = await anon.from('organizations').select('*').eq('id', 'some-org-id');
expect(data).toEqual(null);
expect(error).toBeTruthy();
});
Run your test suite after applying migrations and before flipping any feature flags to enabled in production.
Quick code gist (copy-paste)
RLS + middleware + TanStack Query quick snippet (abbreviated):
-- SQL: profiles policy
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Profiles: self" ON public.profiles FOR SELECT USING (id = auth.uid());
// middleware.ts (edge)
const token = req.cookies.get('sb-access-token')?.value;
const resp = await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/user`, { headers: { Authorization: `Bearer ${token}`, apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY } });
if (!resp.ok) redirect('/login');
// TanStack Query: fetch orgs
export function useOrganizations() {
return useQuery(['orgs'], async () => {
const { data, error } = await supabase.from('organizations').select('*');
if (error) throw error; return data;
});
}
Social media copy (developer-optimized)
- Twitter/X (short):
"Secure multi-tenant SaaS with Supabase + Next.js? Use RLS, server-validated tokens, service-role webhooks, and audit logs. I wrote a step-by-step guide for EliteSaaS: profiles, orgs, invites, tests. #NextJS #Supabase #SaaS" - LinkedIn (developer/business):
"If you're building a multi-tenant SaaS, RLS is non-negotiable. My new EliteSaaS guide shows how to wire Supabase Auth, Next.js middleware, token handling, and RLS policies for profiles, organizations, and membership. Includes code, migration checklist, and tests." - Reddit (r/webdev / r/IndieHackers):
"Practical walkthrough: Supabase Auth + RLS for multi-tenant apps. Covers invites, hashed tokens, service-role webhooks, and preventing last-owner deletion. Looking for feedback from folks who've hardened their RLS in production."
Conclusion & next steps
Securing authentication and enforcing Row-Level Security are the backbone of any multi-tenant SaaS. With Supabase + Next.js you get a compact, auditable, and performant stack — but only if you pair client flows, middleware checks, and RLS policies correctly. Use the patterns in this guide to:
- Keep your service role key server-only and used only in signed contexts (webhooks, migrations).
- Hash invitation tokens and enforce expirations.
- Write RLS policies that mirror your app-level permission model.
- Add server-side audits for permissions changes.
Ready to apply these patterns? Start by enabling RLS on profiles and organization_members, add the SQL policies above, then run the migration checklist and integration tests.
If you want deeper role hierarchies or compliance context, consult the Team Management & RBAC Patterns and Security Deep Dive posts in the EliteSaaS docs. If you’re shipping a SaaS product and want the full starter with these patterns pre‑wired, try EliteSaaS and get your core security done the right way.
Call-to-action: implement these policies in your staging workspace this week and run the tests above — and if you need help, open an issue in your EliteSaaS repo or ask in the community for a migration review.