SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Getting Started
Installation
Configuration
Deployment

Components

  • Hero
  • Pricing
  • Features

Features

  • Authentication
  • Payments
  • Emails
  • Database
  • Blog
  • Security Headers
  • Claude Code Skills

Recipes

  • Add a Server Action
  • Add a Database Table
  • Add an OAuth Provider
  • Add an Email Template
  • Customize the Auth Flow
  • Add an Admin Metric
  • Enable Bot Protection

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:

  1. 'use server' on the file. The function runs only on the server. The service_role key never reaches the browser bundle.
  2. createAdminClient() not createClient(). 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.
  3. 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 to auth.users)
  • full_name
  • avatar_url
  • billing_address, payment_method

customers

Maps Supabase users to Stripe customers. Created lazily on first checkout.

  • id (foreign key to auth.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_id
  • amount, 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, quantity
  • current_period_start, current_period_end
  • cancel_at_period_end

See the payments docs for the full one-time-vs-subscription trade-off.

Adding tables

The checklist for any new table:

  1. Add the CREATE TABLE SQL to supabase/schema.sql.
  2. 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.
  3. Run the migration in the Supabase SQL Editor.
  4. Regenerate types: npx supabase gen types typescript --project-id YOUR_PROJECT > lib/supabase/database.types.ts.
  5. 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.