BUILT INTO EVERY GOODSPEED APP
Row-Level Security (RLS) Baked In
Every Supabase query is scoped to the authenticated user at the database layer via RLS policies, so a bug in app code can never accidentally return another tenant's data.
- Tier: Core
- Status: Static
- Always enabled
WHY IT MATTERS
The most common class of data-access bug in multi-user apps is not a complicated SQL injection or a broken authentication token. It is a forgotten WHERE clause. A developer writes a query to fetch the current user's records, ships it, and then in a refactor three months later someone removes the filter, or copies the query to a new screen and forgets to re-add it. In a traditional setup, that bug ships to production and every user suddenly has access to every other user's data. RLS removes the entire class. Every table in the GAS template has ALTER TABLE ... ENABLE ROW LEVEL SECURITY applied in the base migration, along with policies that tie every SELECT, INSERT, UPDATE, and DELETE to auth.uid(). The Postgres row scanner enforces the filter before any application code executes. Forgetting to add a WHERE clause in the app becomes a harmless omission rather than a tenant-isolation breach, because Postgres simply returns zero rows for cross-user reads regardless of what the query says.
The template's RLS architecture also handles the two scenarios where strict per-user scoping has to flex: admin operations and multi-tenant organizations. Admin routes in the app use a separately configured service-role client that bypasses RLS entirely, and that client is never exposed to browser code or the anon key. Organization-scoped data uses a SECURITY DEFINER helper function called user_org_ids() that returns the set of organization IDs the current user belongs to. Policies on organization tables reference this function in their USING clause, letting members read all rows in their organization without ever giving them cross-org visibility. The SECURITY DEFINER annotation prevents the infinite-recursion error (PostgreSQL 42P17) that self-referential policies would otherwise trigger. A separate is_admin() helper follows the same pattern for admin-role checks, and a BEFORE UPDATE trigger on the profiles table coerces the role column back to its prior value if a non-admin tries to self-escalate via a REST PATCH, closing the privilege-escalation vector that would otherwise exist in a naive update-own-row policy.
HOW IT IS WIRED
Real code from the GAS template
The code below is drawn from supabase/migrations/001_base_schema.sql in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.
-- supabase/migrations/001_base_schema.sql (excerpt)
-- Every table enables RLS. The app client uses the anon key (auth.uid()-scoped).
-- Admin operations use a separate service-role client that bypasses RLS.
alter table public.profiles enable row level security;
create policy "Users can read own profile"
on public.profiles for select
using (auth.uid() = id);
create policy "Users can update own profile"
on public.profiles for update
using (auth.uid() = id);
alter table public.user_bookmarks enable row level security;
create policy "Users can manage own bookmarks"
on public.user_bookmarks for all
using (auth.uid() = user_id);
-- SECURITY DEFINER helper prevents 42P17 infinite-recursion in org-scoped policies.
create or replace function public.user_org_ids(p_user uuid)
returns setof uuid language sql security definer stable as $$
select organization_id from public.organization_members where user_id = p_user;
$$;
-- Organization-scoped read: member sees all rows in their org, never cross-org.
create policy "members_read_membership"
on public.organization_members for select to authenticated
using (user_id = auth.uid()
or organization_id in (select public.user_org_ids(auth.uid())));Source: goodspeed-apps/gas-template → supabase/migrations/001_base_schema.sql
HONEST LIMITS
When Row-Level Security (RLS) Baked In is the wrong choice
Admin dashboards that need to read data across multiple users or tenants cannot use the RLS-scoped anon client. The template handles this by providing a separate service-role client that bypasses RLS entirely. Those routes live behind a separate authentication check and never run in a context where the anon key would be used. If you find yourself adding exceptions to RLS policies to give ordinary users visibility into other users' data, that is a sign the feature you are building should use the service-role client with explicit authorization logic rather than relaxing the RLS boundary. Extremely complex access-control graphs, such as hierarchical org trees with inherited permissions, delegated access, or time-bounded grants, can be difficult to express cleanly in RLS policies without writing very complex SQL that is hard to audit and reason about. In those cases, an application-level authorization layer sitting in front of a service-role client is often more maintainable. RLS works best when the access pattern is straightforward: each row belongs to one user, or to one organization, with a clean membership test at the boundary.
Tier: Core · Static
Evaluate your use case
Check whether row-level security (rls) baked in aligns with your target audience, platform constraints, and regulatory environment before enabling it.
Audit the config
This feature is always enabled. If you need to disable it, remove the corresponding template files after generation.
Seek alternatives
If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.
APPS USING THIS FEATURE
Every generated Goodspeed app includes row-level security (rls) baked in. Browse the ideas catalog to see apps across all categories that ship with this feature wired in.
CAPABILITIES
Row-Level Security (RLS) Baked In capability breakdown
Concrete dimensions of what the built-in row-level security (rls) baked in implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Enforcement layer | RLS policies run at the Postgres row-scan layer. No application code can skip them. | Database (Postgres) |
| Policy scope | Every core table ships with auth.uid() = user_id policies. DevAgent-generated tables follow the same pattern. | Per-user rows |
| Admin bypass | Admin routes use a service-role client that bypasses RLS. The anon-key client cannot be promoted from within app code. | Service-role only |
| SECURITY DEFINER helpers | Cross-table lookups (user_org_ids, is_admin) use SECURITY DEFINER functions to prevent 42P17 infinite-recursion in self-referential policies. | Recursive-safe |
| Privilege escalation guard | A BEFORE UPDATE trigger on profiles coerces profiles.role back to its prior value if the actor is not already an admin, closing self-elevation via REST. | Trigger-enforced |
COMMON QUESTIONS
What happens if I forget to add a WHERE clause filtering by user ID?
Nothing bad happens. That is the core promise of RLS. When a row is read from a table that has RLS enabled, Postgres applies the active policies as an implicit WHERE clause at the scan level, before your query's own filters run. If you write SELECT * FROM items without any user_id filter, the database returns only the rows where auth.uid() matches the user_id column, as specified in the policy. A missing application-level filter becomes a vacuous omission rather than a cross-tenant data leak. The only way to read another user's rows is to use the service-role key, which never touches browser code or the anon-key client.
How does RLS work for organization-shared data?
Organization-scoped tables use a SECURITY DEFINER helper function called user_org_ids() that returns the set of organization IDs the current user belongs to. Policies on those tables reference this function in their USING clause: using (organization_id in (select public.user_org_ids(auth.uid()))). The SECURITY DEFINER annotation allows the helper to query organization_members without triggering the policy on that table itself, avoiding the 42P17 infinite-recursion error that self-referential policies produce. Organization owners get a broader update policy (using owner_user_id = auth.uid()) while members get a read-only view of the organization row. Cross-organization visibility is structurally impossible from the anon-key client.
Can I use RLS with the Supabase client on web and not just native?
Yes. RLS is a Postgres-layer feature. It applies to every request made through the Supabase JavaScript client regardless of platform, including the web export that the gas-template supports. The anon key is safe to ship in the browser because even if an attacker extracts it from the JavaScript bundle, they can only read rows that the authenticated user owns. An unauthenticated request (auth.uid() returns null) will match no rows on any policy that uses auth.uid() = user_id, so the table appears empty rather than returning all rows.
How does the privilege escalation guard work?
Without an additional safeguard, any user could issue a PATCH /profiles/user-id with {role: 'admin'} through the REST API. The base update-own-row policy allows a user to update their own profile row, and without a WITH CHECK clause that policy would not block a role column change. The template adds a BEFORE UPDATE trigger, trg_profiles_protect_role, which runs before every profile update. The trigger function checks whether the new.role value differs from old.role and whether the acting user is already an admin via is_admin(auth.uid()). If both conditions are true (new role, non-admin actor), it coerces new.role back to old.role before the write commits. The caller receives no error, but the self-promotion never lands. Admin accounts can still change role values via the service-role client.
GET IT BUILT INTO YOUR APP