RLS policy patterns
This page collects 8 common RLS permission patterns for Postgres-Native cloud storage. You can copy and paste them straight into your project. Naming aligns with PostgreSQL database — RLS policy patterns on purpose — the same mental model applies to both business tables and cloud storage.
All the templates below assume:
- The bucket already exists (see Quick experience).
- RLS is already enabled on
storage.objects/storage.buckets(handled by initialization). - You do not need to
GRANT ... ON storage.objects— the three roles already haveALL, and the only gate is RLS. auth.uid()returns the current user ID; see Authentication.
1. PUBLIC — fully open
Use cases: static site assets, public artwork, image hosting served via CDN.
CREATE POLICY public_select ON storage.objects
FOR SELECT TO anon, authenticated
USING (bucket_id = 'public-assets');
CREATE POLICY public_insert ON storage.objects
FOR INSERT TO anon, authenticated
WITH CHECK (bucket_id = 'public-assets');
| Operation | anon | authenticated | service_role |
|---|---|---|---|
| SELECT | ✅ | ✅ | ✅ |
| INSERT | ✅ | ✅ | ✅ |
| UPDATE | ❌ | ❌ | ✅ |
| DELETE | ❌ | ❌ | ✅ |
In practice you usually require sign-in for
INSERT.
2. READONLY — everyone reads, only uploader writes
Use cases: UGC images / videos — everyone sees them, only the author can change them.
-- Anyone can read
CREATE POLICY readonly_select ON storage.objects
FOR SELECT TO anon, authenticated
USING (bucket_id = 'ugc');
-- Signed-in users can upload; owner_id must equal themselves (the Storage API injects it)
CREATE POLICY readonly_insert ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (bucket_id = 'ugc' AND owner_id = auth.uid());
-- Only the owner can update
CREATE POLICY readonly_update ON storage.objects
FOR UPDATE TO authenticated
USING (bucket_id = 'ugc' AND owner_id = auth.uid())
WITH CHECK (bucket_id = 'ugc' AND owner_id = auth.uid());
-- Only the owner can delete
CREATE POLICY readonly_delete ON storage.objects
FOR DELETE TO authenticated
USING (bucket_id = 'ugc' AND owner_id = auth.uid());
| Operation | anon | authenticated (owner) | authenticated (other) | service_role |
|---|---|---|---|---|
| SELECT | ✅ | ✅ | ✅ | ✅ |
| INSERT | ❌ | ✅ | ✅ (own only) | ✅ |
| UPDATE | ❌ | ✅ | ❌ | ✅ |
| DELETE | ❌ | ✅ | ❌ | ✅ |
3. PRIVATE — only the uploader can read/write
Use cases: private files (resumes, personal backups, personal cloud drives).
CREATE POLICY private_select ON storage.objects
FOR SELECT TO authenticated
USING (bucket_id = 'private' AND owner_id = auth.uid());
CREATE POLICY private_insert ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (bucket_id = 'private' AND owner_id = auth.uid());
CREATE POLICY private_update ON storage.objects
FOR UPDATE TO authenticated
USING (bucket_id = 'private' AND owner_id = auth.uid())
WITH CHECK (bucket_id = 'private' AND owner_id = auth.uid());
CREATE POLICY private_delete ON storage.objects
FOR DELETE TO authenticated
USING (bucket_id = 'private' AND owner_id = auth.uid());
| Operation | anon | authenticated (owner) | authenticated (other) | service_role |
|---|---|---|---|---|
| All | ❌ | ✅ | ❌ | ✅ |
4. ADMINWRITE — everyone reads, only admins write
Use cases: official asset libraries, icon sets, operational banners.
CREATE POLICY adminwrite_select ON storage.objects
FOR SELECT TO anon, authenticated
USING (bucket_id = 'official');
-- No INSERT/UPDATE/DELETE policy → deny by default for anon / authenticated
-- service_role has BYPASSRLS; the backend writes via an API Key
| Operation | anon | authenticated | service_role |
|---|---|---|---|
| SELECT | ✅ | ✅ | ✅ |
| INSERT/UPDATE/DELETE | ❌ | ❌ | ✅ |
5. ADMINONLY — only admins read/write
Use cases: sensitive credentials, archived backups, internal-only material.
-- No policies at all → everything denied
-- Only service_role (BYPASSRLS) can read/write; backend uses an API Key
| Operation | anon | authenticated | service_role |
|---|---|---|---|
| All | ❌ | ❌ | ✅ |
6. Path-prefix isolation (one folder per user)
Use cases: user avatars, personal cloud drives, IM attachments — all users share one bucket but are physically separated by <uid>/.
Convention: object keys look like <uid>/<filename> or deeper, <uid>/<sub-dir>/....
CREATE POLICY user_folder_select ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'user-files'
AND (storage.foldername(name))[1] = auth.uid()
);
CREATE POLICY user_folder_insert ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'user-files'
AND (storage.foldername(name))[1] = auth.uid()
);
CREATE POLICY user_folder_update ON storage.objects
FOR UPDATE TO authenticated
USING (
bucket_id = 'user-files'
AND (storage.foldername(name))[1] = auth.uid()
)
WITH CHECK (
bucket_id = 'user-files'
AND (storage.foldername(name))[1] = auth.uid()
);
CREATE POLICY user_folder_delete ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'user-files'
AND (storage.foldername(name))[1] = auth.uid()
);
path_tokens is a generated column and can be indexed. For high-throughput access:
CREATE INDEX idx_objects_user_folder
ON storage.objects (bucket_id, (path_tokens[1]));
You can also write path_tokens[1] = auth.uid(), equivalent to (storage.foldername(name))[1] but friendlier to the index.
7. Join business tables (team / multi-tenant)
Use cases: team drives, multi-tenant SaaS — file ownership is dictated by a business table team_members.
Convention: object keys look like <team_id>/<filename>.
-- Business table
CREATE TABLE public.team_members (
team_id text NOT NULL,
user_id text NOT NULL,
role text NOT NULL DEFAULT 'member', -- member / admin
PRIMARY KEY (team_id, user_id)
);
-- Team members can read
CREATE POLICY team_files_select ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'team-files'
AND EXISTS (
SELECT 1 FROM public.team_members tm
WHERE tm.team_id = (storage.foldername(storage.objects.name))[1]
AND tm.user_id = auth.uid()
)
);
-- Team members can upload into their team's folder
CREATE POLICY team_files_insert ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'team-files'
AND EXISTS (
SELECT 1 FROM public.team_members tm
WHERE tm.team_id = (storage.foldername(name))[1]
AND tm.user_id = auth.uid()
)
);
-- Only team admins can delete
CREATE POLICY team_files_delete ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'team-files'
AND EXISTS (
SELECT 1 FROM public.team_members tm
WHERE tm.team_id = (storage.foldername(storage.objects.name))[1]
AND tm.user_id = auth.uid()
AND tm.role = 'admin'
)
);
The EXISTS (SELECT 1 FROM public.team_members ...) subquery runs as the current role, so the authenticated role must have at least SELECT on public.team_members:
GRANT SELECT ON public.team_members TO authenticated;
And the RLS on team_members itself must allow "the user reads their own membership rows".
8. Metadata-driven (share links, content tags)
Use cases: temporary share links, visibility controlled by tags in user_metadata.
-- user_metadata looks like {"share_token": "xyz", "expires_at": "2025-06-01T00:00:00Z"}
CREATE POLICY shared_by_token ON storage.objects
FOR SELECT TO anon, authenticated
USING (
bucket_id = 'share'
AND user_metadata ? 'share_token'
AND current_setting('request.headers', true)::json->>'x-share-token'
= user_metadata->>'share_token'
AND (user_metadata->>'expires_at')::timestamptz > now()
);
This reads the HTTP header
x-share-token;request.headersis the GUC injected by PostgREST and works the same way in the Postgres-Native gateway.
Abstracting policy expressions
When the same predicate is reused across many policies, wrap it in a function:
-- Is the current user the owner of this object?
CREATE OR REPLACE FUNCTION storage.is_owner(obj_name text, obj_owner_id text)
RETURNS boolean
LANGUAGE sql STABLE
AS $$
SELECT obj_owner_id = auth.uid();
$$;
-- Is the current user a member of the given team?
CREATE OR REPLACE FUNCTION public.is_team_member(p_team_id text)
RETURNS boolean
LANGUAGE sql STABLE
AS $$
SELECT EXISTS (
SELECT 1 FROM public.team_members
WHERE team_id = p_team_id AND user_id = auth.uid()
);
$$;
-- Use them in a policy
CREATE POLICY team_files_select ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'team-files'
AND public.is_team_member((storage.foldername(name))[1])
);
Decision table
| Scenario | Recommended pattern |
|---|---|
| Landing-page static assets, fonts, icons | 1. PUBLIC |
| Social feed photos, UGC walls | 2. READONLY |
| Personal note attachments, resumes | 3. PRIVATE |
| Official asset library, campaign banners | 4. ADMINWRITE |
| Internal backups, sensitive credentials | 5. ADMINONLY |
| User avatars, personal cloud drives | 6. Path-prefix isolation |
| Team drives, multi-tenant SaaS | 7. Join business tables |
| Temporary share links, tag filtering | 8. Metadata-driven |
| Article covers, order attachments — "follows the business object" | 7. Join business tables (variant) |
Next steps
- Quick experience — runs pattern 6 end-to-end
- Permission management —
storageschema, helpers, the RLS-only model - PostgreSQL database — RLS policy patterns — same patterns on the business-table side
- FAQ