Production Supabase Auth: login is only the beginning
Build Supabase Auth with an explicit session lifecycle, redirect allowlist, Row Level Security, and a server-only service-role boundary.
Supabase Auth answers “who does this request represent?” It does not answer “which row may this person read?” PostgreSQL grants and Row Level Security (RLS) enforce the data authorization boundary. Checking user in a React route, hiding an admin button, or placing a page under a protected layout improves user experience, but an attacker can still call the Data API directly. As of 2026-07-29, Supabase's official documentation still explains that Auth tokens accompany SDK data requests and work with RLS for row-by-row access control. It also requires RLS on tables in exposed schemas, normally public . A service-role or secret key may bypass RLS, so it belongs only in a server or Edge Function. Never place it in a VITE_ environment variable or browser bundle. Implementation steps Start with an identity and authorization model. List the actual operations required by anonymous visitors, signed-in members, content authors, support staff, and administrators. Put product entitlements, team memberships, and ownership in database-verifiable fields. Do not use user-editable metadata as the basis for an administrator role. Supabase's RLS guide distinguishes user metadata from app metadata, but complex authorization is often clearer in protected relational tables. Choose the client model deliberately. A browser-only SPA uses @supabase/supabase-js for the browser session. An SSR framework follows the current official server-side guide and uses its supported SSR package so the server can read a cookie-based session with PKCE. Do not let multiple clients compete over different storage keys and refresh ownership in one app; that creates refresh loops and inconsistent sign-out. Configure each Auth provider, Site URL, and redirect allowlist separately for development, preview, and production. Validate a post-callback next path against a local allowlist. Email links and OAuth state must originate from the expected flow, not accept an arbitrary external redirect. Create tables and constraints, enable RLS immediately, and then add minimum grants and policies. This example lets a user read and update only their profile. A production schema needs appropriate required fields, indexes, and migration tests: create table public.profiles ( id uuid primary key references auth.users(id) on delete cascade, display_name text, created_at timestamptz not null default now() ); alter table public.profiles enable row level security; grant select, update on public.profiles to authenticated; create policy "users read own profile" on public.profiles for select to authenticated using ((select auth.uid()) = id); create policy "users update own profile" on public.profiles for update to authenticated using ((select auth.uid()) = id) with check ((select auth.uid()) = id); USING filters existing rows. WITH CHECK constrains the row after mutation. An update also requires a corresponding select policy to work as expected. For public-read and private-write data, create separate SELECT and mutation policies instead of one broad FOR ALL shortcut. Handle profile creation explicitly. A database trigger can create a one-to-one profile during signup, but a failed trigger may fail the signup transaction. Keep the function minimal, fix its search_path , test it, and expose clear errors. Alternatively, an authenticated first-login upsert can create the profile, but its policy must require id = auth.uid() . The client must never choose another user's ID. Create a privileged server boundary for administrative notifications, payment settlement, or support access that legitimately needs service-role capabilities. Put the operation in an authenticated Edge Function or server route, verify the caller first, and execute a narrow RPC. Do not pass an admin client into shared browser helpers. Never return keys, JWTs, or detailed database errors. Design the whole session lifecycle. Auth state listeners can update UI for sign-in, sign-out, and refresh, while data requests still handle 401 and 403. Sensitive operations may require recent authentication or MFA according to plan capability and threat model. Account deletion, email change, password reset, suspension, and global sign-out all need server-side effects and audit, not merely a local-storage clear. Failure and recovery “Login succeeded but no data appears” is often RLS correctly denying a request, not a broken SDK. Inspect the request role, whether a user ID is present, table grants, and policy conditions before changing code. Never respond by disabling RLS. Prove with two test users that A cannot read B, and run the anonymous case separately. “Everyone can read everything” usually means RLS was never enabled, a USING (true) policy is too broad, a definer view bypasses policies, or a normal request uses a service-role client. Remove the exposure path, rotate a potentially leaked key, restore RLS, and use access logs to bound impact. Hiding a front-end button is not remediation. For repeated SSR sign-outs, check whether the server adapter can write refreshed cookies, whether callback and middleware options match, and whether a CDN cached a response containing Set-Cookie . A rollback may return to one previously verified client flow, but must not place tokens in URLs or logs. If a policy migration denies all production access, restore the saved grants and policy DDL rather than disabling RLS for the whole table. Every policy change should be replayable locally or in staging using anonymous, two authenticated users, and the privileged server role. Verification commands Run policy tests against local Supabase or an isolated staging project. The minimum matrix covers anonymous, owner, other user, and admin/server roles across SELECT, INSERT, UPDATE, and DELETE: select schemaname, tablename, policyname, roles, cmd, qual, with_check from pg_policies where schemaname = 'public' order by tablename, policyname; select relname, relrowsecurity from pg_class join pg_namespace on pg_namesp