Database
Supabase security model, RLS deny-all posture, and the backend-only data access pattern.
What's enforced
RLS is enabled on every table. No RLS policies are created. That combination means the anon key has zero data access; every SELECT, INSERT, UPDATE, and DELETE against the database denies by default. Even if a anon key leaks, it cannot read or write any row.
All data access lives in Server Actions and API routes, using createAdminClient() (the service_role key). The browser Supabase client is used only for authentication (sign in, sign up, sign out, OAuth callback). Never for data queries.
This is the backend-only data access pattern, applied at the architectural level.
Data access pattern
Every query goes through a Server Action. The pattern:
// actions/your-feature.ts
'use server'
import { createAdminClient } from '@/lib/supabase/server'
export async function getData() {
const supabase = createAdminClient()
const { data, error } = await supabase
.from('your_table')
.select('*')
if (error) return { error: 'Failed to fetch data' }
return { data }
}
Three properties of this pattern are doing the security work:
'use server'on the file. The function runs only on the server. Theservice_rolekey never reaches the browser bundle.createAdminClient()notcreateClient(). The admin client bypasses RLS (intentional, because Server Actions are the trusted access layer). The browser client respects RLS and gets denied by the deny-all posture.- Generic error message returned to the client. The database error stays in server logs; the client gets
'Failed to fetch data', which prevents leaking schema details to a probing attacker.
For more, see the Next.js security hardening checklist and Server Actions + Zod, the complete guide.
Database schema
The schema is defined in supabase/schema.sql and creates four tables.
profiles
Extends auth.users with app-specific fields. Auto-created via a database trigger on signup, so every authenticated user has a profile row immediately.
id(foreign key toauth.users)full_nameavatar_urlbilling_address,payment_method
customers
Maps Supabase users to Stripe customers. Created lazily on first checkout.
id(foreign key toauth.users)stripe_customer_id
purchases
Tracks one-time payments (the default billing mode). The webhook handler inserts here on checkout.session.completed.
id(Stripe payment intent ID, primary key for idempotency)user_id,product_idamount,currency,status
subscriptions
Tracks subscription state. Unused by the default one-time billing flow, but kept in the schema so switching to subscription mode does not require a migration. The webhook handler will populate this table automatically if you switch createCheckoutSession to mode: 'subscription'.
id(Stripe subscription ID)user_id,status,price_id,quantitycurrent_period_start,current_period_endcancel_at_period_end
See the payments docs for the full one-time-vs-subscription trade-off.
Adding tables
The checklist for any new table:
- Add the
CREATE TABLESQL tosupabase/schema.sql. - Enable RLS on the new table:
ALTER TABLE public.your_table ENABLE ROW LEVEL SECURITY;. Do this even if you do not plan to write policies; the deny-all posture is the default. - Run the migration in the Supabase SQL Editor.
- Regenerate types:
npx supabase gen types typescript --project-id YOUR_PROJECT > lib/supabase/database.types.ts. - Create Server Actions in
actions/for read and write operations. Validate inputs with Zod. Return generic errors.
For complex authorization (e.g., users seeing only their own rows), add a Row Level Security policy explicitly. The RLS deny-all posture means missing policies fail closed; you opt in to access, not out of it.
For multi-tenant patterns, see multi-tenancy and RBAC in Supabase, the secure pattern.