· supabase / postgresql / rls

Những cạm bẫy trong Supabase RLS — bài học từ production

7 cạm bẫy Supabase RLS: NULL từ auth.uid(), service_role bị lộ, policy 171ms, đệ quy vô hạn, leo thang đặc quyền — kèm cách sửa SQL cụ thể từng trường hợp.

Bởi

3.414 từ · 18 phút đọc

Row Level Security là lựa chọn đúng đắn cho các ứng dụng Supabase. Chúng tôi không có ý thuyết phục bạn từ bỏ nó.

Điều chúng tôi muốn nói là: khoảng cách giữa “đã bật RLS” và “thực sự bảo mật” xa hơn nhiều so với những gì tài liệu chính thức mô tả, và các lỗi thường xảy ra trong im lặng. Một policy gọi trực tiếp auth.uid() — thay vì (select auth.uid()) — có thể chạy mất 171ms cho mỗi row trên bảng 100K rows. Một mệnh đề WITH CHECK bị bỏ sót sẽ âm thầm chấp nhận các lần ghi mà bạn không hề có ý định cho phép. Role anon mặc định có quyền truy cập bảng, và quyền này sẽ không tự động bị thu hồi cho đến khi một thay đổi trên nền tảng được triển khai.

Chúng tôi đã thử nghiệm với Supabase JS v2.97.0, PostgreSQL 15+, và ba authentication path: anon key, service_role, và authenticated JWT. Dưới đây là 7 cạm bẫy ảnh hưởng nặng nhất đến các ứng dụng Supabase trên production, kèm theo cách khắc phục cụ thể cho từng trường hợp.

Bài này dành cho ai

Các developer đang vận hành Supabase trên production, hoặc đang đánh giá khả năng mở rộng của nó. Cụ thể là những người đã bật RLS, thêm policy, và nghĩ rằng như vậy là đủ.

Nếu bạn vẫn đang cân nhắc giữa Supabase và Firebase, Supabase vs Firebase phân tích sự khác biệt về kiến trúc. Nếu bạn đang so sánh các lựa chọn database hosting, Neon vs Supabase là bài cần đọc. Bài này dành cho những người đã chọn Supabase và đang vận hành nó với người dùng thực.

Môi trường thử nghiệm

  • Supabase JS v2.97.0
  • PostgreSQL 15+ (Supabase hosted + local CLI với supabase start)
  • Authentication path: anon key, service_role, authenticated JWT
  • Hiệu năng policy: EXPLAIN ANALYZE trên bảng documents 100K rows, đo từng policy riêng lẻ
  • RLS test suite: supabase-test-helpers v0.0.6, pgTAP, supabase test db CLI ≥1.11.4

Pitfall 1: auth.uid() trả về NULL và policy âm thầm thông qua

Đây là lỗi thầm lặng phổ biến nhất: một policy dùng auth.uid() để giới hạn quyền truy cập, nhưng auth.uid() lại trả về NULL đúng lúc policy được chạy. Không có thông báo lỗi. Có row trả về hay không — và bạn không biết kết quả nào là đúng.

Có hai nguyên nhân gốc rễ riêng biệt.

Nguyên nhân A: anonymous request

Anon key là một service key. Nó không đại diện cho một người dùng cụ thể. auth.uid() trả về NULL cho bất kỳ request nào được xác thực bằng anon key. Nếu policy của bạn là:

CREATE POLICY "users can read own rows"
ON documents
FOR SELECT
USING (auth.uid() = user_id);

Một anon request sẽ tính toán NULL = user_id. PostgreSQL xử lý NULL = bất kỳ thứ gì là NULL, không phải false — nên không có row nào khớp, trông có vẻ hoạt động đúng. Điều đó đúng, miễn là bạn không để role anon có quyền SELECT trên bảng. Nếu có (xem Pitfall 6), dữ liệu sẽ bị lộ ra ngoài.

Nguyên nhân B: JWT algorithm mismatch (GitHub #43066)

Một vấn đề đã được báo cáo: GoTrue ký JWT bằng ES256, trong khi PostgREST mong đợi HS256. Khi hai bên không đồng thuận về thuật toán, PostgREST âm thầm fallback về role anon thay vì trả về lỗi 401 — nên các request mang JWT hợp lệ lại bị xử lý như unauthenticated, và auth.uid() trả về NULL. Thread trên GitHub #43066 vẫn chưa được giải quyết; nếu bạn thấy các authenticated request đột nhiên hoạt động như anonymous, hãy kiểm tra xem phiên bản PostgREST và GoTrue của bạn có dùng chung một thuật toán không.

Nguyên nhân C: stale app_metadata trong JWT

Một lớp lỗi liên quan: các claim về role hoặc quyền được cache trong JWT không phản ánh những thay đổi gần đây trong database cho đến khi token được làm mới. Một user vừa được nâng từ member lên admin trong bảng user_roles vẫn mang member trong JWT cho đến khi họ đăng xuất hoặc bạn buộc refresh. Các policy đọc auth.jwt() -> 'app_metadata' đang hoạt động trên dữ liệu cũ.

Cách tiếp cận an toàn là đọc dữ liệu role từ database thông qua một security definer function, thay vì từ JWT:

CREATE OR REPLACE FUNCTION public.get_user_role(user_id uuid)
RETURNS text
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
  SELECT role FROM public.user_roles WHERE id = user_id;
$$;

-- Policy đọc từ database, không phải từ JWT
CREATE POLICY "admin access"
ON sensitive_table
FOR SELECT
USING (public.get_user_role((select auth.uid())) = 'admin');

Chú ý cách bọc (select auth.uid()) — đó chính là cách khắc phục trong Pitfall 3 được áp dụng ở đây. Function chỉ được gọi một lần cho toàn bộ query, không phải mỗi row.

Pitfall 2: service_role bỏ qua RLS — CVE-2025-48757 ảnh hưởng 10.3% ứng dụng

service_role bỏ qua RLS hoàn toàn. Đó là thiết kế cố ý. Vấn đề xảy ra khi RLS bị thiếu hoặc cấu hình không đầy đủ, hoặc khi service_role key lọt vào ngữ cảnh không nên có.

CVE-2025-48757, được công bố tháng 5/2025, là ví dụ cụ thể của trường hợp đầu tiên. Một cuộc quét trên 1.645 ứng dụng của nền tảng Lovable phát hiện 303 endpoint trên 170 dự án (10.3%) có các bảng Supabase có thể đọc được bởi request không xác thực qua anon key. Nguyên nhân gốc là RLS bị thiếu hoặc cấu hình không đầy đủ: các bảng mở quyền truy cập cho role anon mà không có policy hạn chế nào. Không phải do service_role bị lộ — mà là lỗi cấu hình RLS thuần túy, với anon key làm đúng những gì nó được thiết kế để làm trên một bảng không được bảo vệ.

Một antipattern nghiêm trọng hơn: service_role key được đặt trong frontend code.

// Sai: service_role trong browser context
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY // được đóng gói vào client JS
);

Bất kỳ biến nào có tiền tố NEXT_PUBLIC_ đều được nhúng vào client bundle. Ai mở DevTools cũng thấy service_role key của bạn. Với key đó, RLS trên mọi bảng đều bị bỏ qua.

Cách phân tách đúng:

// Browser: luôn dùng anon key
const browserClient = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY);

// Chỉ dùng phía server: service_role cho các tác vụ admin
const adminClient = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY);

Supabase đã thông báo về việc tách biệt publishable key (an toàn để public) và secret key (không bao giờ public) — xem Supabase Security Retrospective 2025 để hiểu bối cảnh. Nếu bạn đang dùng dự án cũ, hãy kiểm tra dashboard; đảm bảo server-only key không nằm trong bất kỳ biến môi trường nào dành cho client.

Các bước kiểm tra: xem lại git history để tìm commit vô tình chứa file .env, chạy bundle analyzer và tìm service_role trong output, đồng thời xác nhận chỉ có các server-side API route — không phải client component — mới gọi adminClient.

Pitfall 3: Hiệu năng policy — 171ms xuống còn 0.1ms với một thay đổi nhỏ

Cách khắc phục hiệu năng có tác động lớn nhất cho RLS: bọc auth.uid() trong một subquery.

Nguyên nhân: auth.uid() được đánh dấu là VOLATILE theo mặc định. PostgreSQL chạy một hàm VOLATILE một lần cho mỗi row trong quá trình scan. Với bảng 100K rows, đó là 100.000 lần gọi cho mỗi SELECT.

Đo trên bảng documents với 100K rows:

-- Chưa tối ưu
EXPLAIN ANALYZE
SELECT * FROM documents WHERE user_id = auth.uid();
-- Planning time: 0.3ms
-- Execution time: 171ms

-- Đã tối ưu
EXPLAIN ANALYZE
SELECT * FROM documents WHERE user_id = (select auth.uid());
-- Planning time: 0.4ms
-- Execution time: 0.1ms

Pattern (select auth.uid()) bọc lời gọi trong một initPlan. PostgreSQL chạy nó một lần cho toàn bộ query rồi thay thế kết quả vào predicate của scan. Semantics hoàn toàn giống nhau; tốc độ thực thi nhanh hơn 1.710× trên workload này.

Áp dụng cách sửa trực tiếp trong định nghĩa policy, không chỉ trong các câu query riêng lẻ:

-- Chậm: auth.uid() được gọi mỗi row
CREATE POLICY "user owns row"
ON documents
FOR SELECT
USING (auth.uid() = user_id);

-- Nhanh: auth.uid() chỉ được gọi một lần mỗi query
CREATE POLICY "user owns row"
ON documents
FOR SELECT
USING ((select auth.uid()) = user_id);

Pattern (select ...) tương tự cũng áp dụng cho các lời gọi security definer function bên trong policy (như pattern đã trình bày trong Pitfall 1). Các hàm đó cũng là VOLATILE theo mặc định; cách đánh giá per-row cũng áp dụng tương tự, và cách khắc phục là như nhau.

Chạy EXPLAIN ANALYZE trên production yêu cầu quyền truy cập Postgres trực tiếp. Với Supabase Pro, bạn có một Postgres instance riêng và lưu trữ query log — có nghĩa là bạn có thể chạy EXPLAIN ANALYZE trên workload production thực tế thay vì bản replica tổng hợp. Ở gói miễn phí, bạn chỉ đo được trên dữ liệu local, thường không phản ánh đúng số lượng row và access pattern trên production.

Pitfall 4: Đệ quy vô hạn trong policy tự tham chiếu

Trường hợp này ném ra lỗi thay vì âm thầm thất bại, nhưng thông báo lỗi không giúp được gì nhiều:

ERROR: infinite recursion detected in policy for relation "organization_memberships"

Pattern thường gặp: bạn có bảng organization_memberships và một policy hạn chế quyền truy cập chỉ dành cho thành viên trong cùng tổ chức. Policy đó truy vấn organization_memberships để kiểm tra tư cách thành viên — điều này kích hoạt policy — policy lại truy vấn organization_memberships lần nữa.

-- Đệ quy vô hạn
CREATE POLICY "members can see members"
ON organization_memberships
FOR SELECT
USING (
  organization_id IN (
    SELECT organization_id FROM organization_memberships
    WHERE user_id = (select auth.uid())
  )
);

Cách khắc phục là dùng một hàm SECURITY DEFINER truy vấn bảng bên ngoài RLS:

CREATE OR REPLACE FUNCTION public.get_user_organizations()
RETURNS SETOF uuid
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
  SELECT organization_id
  FROM organization_memberships
  WHERE user_id = auth.uid();
$$;

-- Policy gọi hàm, hàm bỏ qua quá trình kiểm tra policy
CREATE POLICY "members can see members"
ON organization_memberships
FOR SELECT
USING (organization_id IN (SELECT public.get_user_organizations()));

SECURITY DEFINER khiến hàm chạy với đặc quyền của người định nghĩa nó (thường là superuser trong Supabase), bỏ qua policy trên chính bảng đó. Điều này phá vỡ vòng đệ quy.

Pattern tương tự xuất hiện ở bất kỳ đâu bạn có bảng access control tự tham chiếu: team_members, project_collaborators, user_follows. Nếu policy của bạn tham chiếu chính bảng mà nó đang bảo vệ, bạn cần cách khắc phục này. Cộng đồng Supabase có thảo luận mở rộng về các biến thể tại GitHub discussion #3328.

Pitfall 5: Thiếu WITH CHECK cho phép leo thang đặc quyền

USINGWITH CHECK là hai mệnh đề khác nhau với ngữ nghĩa khác nhau:

  • USING — lọc những row nào được hiển thị cho SELECT, và những row nào có thể là mục tiêu của UPDATE và DELETE
  • WITH CHECK — xác thực trạng thái của row sau khi INSERT hoặc UPDATE

Một lỗi phổ biến trong UPDATE policy: bạn chỉ viết mệnh đề USING. Mặc định của PostgreSQL khi WITH CHECK bị thiếu là dùng biểu thức USING cho cả lọc lẫn xác thực kết quả — nhưng việc xác thực áp dụng cho row ở trạng thái trước khi update, không phải sau.

Cách leo thang đặc quyền xảy ra: một user sở hữu một row (vượt qua USING) có thể cập nhật trường user_id của row đó thành UUID của một user khác. USING kiểm tra quyền sở hữu ngay từ đầu. WITH CHECK (nếu có) sẽ xác thực quyền sở hữu của row sau khi update. Không có nó, bản cập nhật được thông qua và user mục tiêu đột nhiên sở hữu một row họ chưa bao giờ tạo ra.

-- Dễ bị tấn công: không có WITH CHECK, UPDATE có thể đổi user_id thành bất kỳ giá trị nào
CREATE POLICY "user can update own rows"
ON documents
FOR UPDATE
USING ((select auth.uid()) = user_id);

-- Đã sửa: WITH CHECK khớp với USING — row phải thuộc về user cả trước và sau khi update
CREATE POLICY "user can update own rows"
ON documents
FOR UPDATE
USING ((select auth.uid()) = user_id)
WITH CHECK ((select auth.uid()) = user_id);

Với các cột nhạy cảm như user_id, role, và plan, hãy thêm REVOKE ở cấp độ cột như một lớp bảo vệ thứ hai:

-- Kiểm tra quyền ở cấp cột xảy ra trước khi RLS — đây là rào cản cứng
REVOKE UPDATE (user_id, role) ON documents FROM authenticated;

REVOKE ở cấp cột được áp dụng trước khi RLS được đánh giá. Dù policy có cho phép cập nhật, PostgreSQL cũng từ chối trước. Hãy dùng cả mệnh đề WITH CHECK và revoke ở cấp cột cho những cột kiểm soát quyền sở hữu hoặc mức truy cập.

Pitfall 6: Role anon có quyền truy cập bảng theo mặc định

Các bảng mới trong Supabase mặc định cấp quyền SELECT, INSERT, UPDATE, DELETE cho role anon — một hành vi mà Supabase đã thông báo sẽ thay đổi trong bản cập nhật nền tảng tương lai, nhưng chưa được triển khai tại thời điểm viết bài. Việc bật RLS trên bảng không xóa những quyền này — nó chỉ thêm lớp kiểm tra policy lên trên. Nếu các RLS policy của bạn không hạn chế rõ ràng role anon, người dùng ẩn danh có thể tương tác với bảng của bạn thông qua bất kỳ policy nào áp dụng (kể cả các policy không lọc theo role, mặc định áp dụng cho tất cả role).

Một ví dụ cụ thể: luồng reset mật khẩu của Supabase lưu token tạm thời trong một bảng có thể truy cập bởi role anon. Nếu bạn sao chép pattern đó mà không có các policy guard đúng, người dùng ẩn danh có thể đọc hoặc chèn vào thứ mà bạn kỳ vọng là tài nguyên chỉ dành cho authenticated user.

Hãy kiểm tra các bảng của bạn ngay bây giờ — đừng chờ đến khi nền tảng thay đổi:

SELECT
  schemaname,
  tablename,
  has_table_privilege('anon', schemaname || '.' || tablename, 'SELECT') AS anon_select,
  has_table_privilege('anon', schemaname || '.' || tablename, 'INSERT') AS anon_insert,
  has_table_privilege('anon', schemaname || '.' || tablename, 'UPDATE') AS anon_update,
  rowsecurity AS rls_enabled
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;

Các bảng có anon_select = truerls_enabled = true chưa chắc đã bị lộ — hãy xác nhận rằng bạn có policy hạn chế cho role anon trên từng bảng. Các bảng có anon_select = truerls_enabled = false hoàn toàn mở với anonymous request.

Để xóa quyền anon trên một bảng:

REVOKE ALL ON your_table FROM anon;

Thay đổi nền tảng theo kế hoạch chỉ áp dụng cho các bảng mới. Nó sẽ không tự động sửa các bảng hiện có. Hãy chạy câu query kiểm tra.

Pitfall 7: RLS test có lỗ hổng thầm lặng

Các policy SELECT chặn quyền truy cập trả về 0 rows — không phải lỗi. Một test chỉ kiểm tra “query không throw exception” sẽ pass dù policy đang chặn mọi thứ. Policy INSERT thì khác: một INSERT bị chặn sẽ ném ra lỗi 42501, và test của bạn thấy được.

Sự bất đối xứng này có nghĩa là SELECT policy test cần xác nhận số lượng row, không chỉ kiểm tra sự vắng mặt của exception:

BEGIN;
SELECT plan(3);

-- Tạo user và authenticate
SELECT tests.create_supabase_user('[email protected]');
SELECT tests.authenticate_as('[email protected]');

-- Xác nhận authenticated user thấy đúng số row của họ
SELECT results_eq(
  'SELECT count(*)::int FROM documents WHERE user_id = auth.uid()',
  ARRAY[2],
  'authenticated user thấy đúng 2 documents của mình'
);

-- Xác nhận authenticated user không thấy row của người khác
SELECT results_eq(
  'SELECT count(*)::int FROM documents WHERE user_id != auth.uid()',
  ARRAY[0],
  'authenticated user không thấy documents của người khác'
);

-- Xác nhận anon không thể insert
SELECT throws_ok(
  $$ INSERT INTO documents (title, user_id) VALUES ('test', gen_random_uuid()) $$,
  '42501',
  NULL,
  'anon insert bị từ chối'
);

SELECT * FROM finish();
ROLLBACK;

Ví dụ trên dùng supabase-test-helpers v0.0.6 với pgTAP. Chạy bằng:

# Yêu cầu supabase CLI ≥1.11.4
supabase test db

Hạn chế: supabase test db chạy trên database phát triển local từ supabase start. Nếu bạn đã áp dụng migration trên production mà chưa có trong thư mục migration local, test của bạn đang kiểm tra một schema khác với những gì đang chạy trên production. Khắc phục trong CI:

supabase db pull  # đồng bộ schema từ remote trước khi chạy test
supabase test db

Với Supabase Pro, bạn có tính năng query log retention và point-in-time recovery, hỗ trợ khi một lỗi policy chỉ xuất hiện trên production và bạn cần tái hiện lại trạng thái database tại thời điểm đó. Ở gói miễn phí, log bị xóa rất nhanh.

Đánh giá: RLS, application-level auth, hay hybrid?

Dùng RLS khi dữ liệu của bạn là multi-tenant, frontend giao tiếp trực tiếp với Supabase mà không qua backend proxy, hoặc bạn cần bảo đảm bảo mật ngay cả khi tầng application bị tấn công. RLS chạy bên trong Postgres — một lỗi trong API layer không thể bỏ qua được nó.

Dùng application-level auth khi các quy tắc truy cập quá phức tạp để viết bằng SQL: quyền truy cập theo thời gian, quyền được tính toán từ nhiều bảng, quy tắc phụ thuộc vào trạng thái bên ngoài (hệ thống entitlement của bên thứ ba, feature flag service), hoặc các access pattern thay đổi thường xuyên đến mức migrate SQL policy quá chậm.

Hybrid là pattern phổ biến nhất ở quy mô lớn: RLS như một lớp bảo vệ dự phòng, với application-level checks là tầng kiểm soát chính. Database từ chối bất cứ điều gì tầng application bỏ sót. Cách này xử lý tình huống “middleware bị tấn công” mà không yêu cầu toàn bộ logic truy cập phải nằm trong SQL.

Nếu bạn đang xây dựng dự án mới và muốn loại bỏ hoàn toàn sự phụ thuộc vào JWT trong access control, Neon kết hợp với Clerk hoặc Auth.js tách biệt tầng auth và data rõ ràng hơn. Đánh đổi: bạn mất tích hợp auth có sẵn của Supabase và phải tự wire user context vào mọi đường dẫn truy cập dữ liệu. Đáng cân nhắc nếu access model của bạn phức tạp hoặc team thấy việc debug RLS policy tốn kém. Xem Clerk vs Supabase Auth để so sánh chi phí tại mốc 50K MAU.

Lưu ý

Vấn đề JWT algorithm mismatch mô tả trong nguyên nhân B (Pitfall 1, GitHub #43066) chưa được xác nhận đã giải quyết — kiểm tra issue được liên kết để biết cập nhật mới nhất. Nguyên nhân anon và stale app_metadata áp dụng bất kể phiên bản client nào.

Các con số hiệu năng (171ms so với 0.1ms) đo trên bảng 100K rows với sequential scan. Kết quả thực tế của bạn phụ thuộc vào kích thước bảng, độ phủ index, và liệu user_id có được đánh index không. Hãy đo trên dữ liệu thực của bạn trước khi báo cáo kết quả.

Thay đổi nền tảng về quyền anon theo kế hoạch chưa được triển khai tại thời điểm thử nghiệm. Hãy xác nhận hành vi hiện tại cho phiên bản dự án cụ thể của bạn.

toolchew có quan hệ affiliate với Supabase và Neon. Mối quan hệ đó không ảnh hưởng đến các phát hiện — nếu RLS đủ tệ để khuyến nghị tránh dùng, chúng tôi sẽ nói thẳng.

Tài liệu tham khảo