Row Level Security Supabase: ai cầm key cũng chỉ đọc được bài
Row Level Security Supabase qua ví dụ blog Next.js: vì sao grant all cho anon nguy hiểm, cách bật RLS, viết policy chỉ cho đọc bài đã đăng và thử từng thao tác.
Row Level Security Supabase là gì? Đó là tính năng của PostgreSQL cho phép database tự quyết định mỗi người được thấy và sửa những dòng nào trong một bảng. Nhờ nó, khóa nằm trong trình duyệt chỉ đọc được bài đã đăng. Bạn có chắc blog của mình không ai sửa trộm được không? Cùng mình kiểm tra nhé!
Bảng posts đã có, sao trang web chưa đọc được?
Ở bài trước, mình tạo bảng posts cho blog-mini bằng migration, với các ràng buộc chặn dữ liệu sai. Bảng đã có, nhưng trang Next.js chưa đọc được nó. Trang gọi Supabase bằng khóa publishable, và database coi mọi request dùng khóa này mà chưa đăng nhập là vai anon.
Theo thông báo của Supabase, từ ngày 30/10/2026, bảng mới trong schema public không còn tự động mở cho Data API. Vai anon chưa được cấp quyền gì trên bảng posts, nên trang nhận về lỗi "permission denied".
Bài này cấp quyền đọc cho đúng. Cuối bài, người đọc chỉ thấy bài đã đăng, không thấy bài nháp hay bài hẹn giờ, và không sửa hay xóa được gì, kể cả khi họ lấy khóa ra khỏi trình duyệt và tự gọi API.
Vì sao khóa publishable không phải là bí mật?
Trang Next.js đọc khóa từ biến môi trường NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY. Mọi biến bắt đầu bằng NEXT_PUBLIC_ được Next.js đưa thẳng vào mã JavaScript gửi xuống trình duyệt. Ai mở DevTools, vào tab Network và xem một request tới Supabase cũng thấy khóa này trong header.
Vậy nên bạn phải thiết kế với giả định là cả thế giới đều có khóa. Bất kỳ ai cũng có thể mở terminal và gửi request DELETE tới bảng posts bằng đúng khóa của blog. Thứ duy nhất đứng giữa họ và dữ liệu của bạn là những gì database cho phép vai anon làm.
Cấp hết quyền cho anon thì chuyện gì xảy ra?
Khi trang báo "permission denied", cách sửa nhanh nhất là một dòng grant all on public.posts to anon. Mình đoán nhiều bạn từng làm vậy, vì trang chạy lại ngay. Mình thử xem cái giá của dòng đó.
Để chạy được trên máy mà không cần tài khoản Supabase, mình dùng PGlite như bài trước, một bản PostgreSQL thật chạy trong Node.js. Supabase tạo sẵn hai vai anon và authenticated, còn PGlite thì không, nên file dưới đây tự tạo chúng. Nó cũng có một hàm voiVaiAnon để chạy một câu SQL dưới vai anon rồi in kết quả, giống như một request từ trình duyệt:
// Dựng một database giống Supabase ở mức tối thiểu, bằng PGlite (Postgres thật chạy trong Node).
// Supabase tạo sẵn hai vai anon và authenticated; ở đây mình tự tạo để thử được ngay trên máy.
import { readdirSync, readFileSync } from "node:fs";
import { PGlite } from "@electric-sql/pglite";
// soMigration: chạy bao nhiêu file migration đầu tiên (theo thứ tự tên), rồi chạy seed.sql.
export async function taoDb(soMigration) {
const db = new PGlite();
await db.exec("create role anon nologin; create role authenticated nologin;");
for (const file of readdirSync("supabase/migrations").sort().slice(0, soMigration)) {
await db.exec(readFileSync(`supabase/migrations/${file}`, "utf8"));
}
await db.exec(readFileSync("supabase/seed.sql", "utf8"));
return db;
}
// Chạy một câu SQL dưới vai anon (người đọc chưa đăng nhập), rồi trả lại vai ban đầu.
// In ra số dòng trả về / bị ảnh hưởng, hoặc thông báo lỗi của Postgres.
export async function voiVaiAnon(db, moTa, sql) {
await db.exec("set role anon");
try {
const kq = await db.query(sql);
const chiTiet = kq.rows.length > 0 ? kq.rows.map((r) => Object.values(r).join(" | ")).join("; ") : `${kq.affectedRows ?? 0} dòng bị ảnh hưởng`;
console.log(`${moTa}: ${chiTiet}`);
} catch (error) {
console.log(`${moTa}: LỖI ${error.message}`);
} finally {
await db.exec("reset role");
}
}
export async function demBai(db) {
const kq = await db.query("select count(*)::int as n from public.posts");
return kq.rows[0].n;
}Dự án có cấu trúc như sau. Hai migration đầu là của bài trước, mình chép lại nguyên văn ở cuối mục này để bạn không phải mở bài cũ:
04-row-level-security/
package.json
supabase/
migrations/
20261110080000_tao_bang_bai_viet.sql bảng posts (bài trước)
20261110090000_them_the_bai_viet.sql cột tags (bài trước)
20261111080000_bat_rls_bai_viet.sql grant + RLS + policy (bài này)
seed.sql bốn bài mẫu
scripts/
gia-lap-supabase.mjs tạo database và vai anon
grant-all.mjs cách sửa nhanh và hậu quả
rls-chua-co-policy.mjs bật RLS mà quên policy
thu-rls.mjs thử mọi thao tác sau khi làm đúng{
"name": "blog-mini-04-row-level-security",
"private": true,
"type": "module",
"devDependencies": {
"@electric-sql/pglite": "0.3.14"
}
}create table public.posts (
id bigint generated always as identity primary key,
slug text not null unique
constraint posts_slug_format check (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),
title text not null
constraint posts_title_length check (char_length(title) between 1 and 200),
description text not null default '',
body_markdown text not null default '',
status text not null default 'draft'
constraint posts_status_known check (status in ('draft', 'published')),
published_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint posts_published_has_date check (status = 'draft' or published_at is not null)
);
create index posts_published_at_idx on public.posts (published_at desc) where status = 'published';
create function public.set_updated_at()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
new.updated_at := now();
return new;
end;
$$;
create trigger posts_set_updated_at
before update on public.posts
for each row execute function public.set_updated_at();alter table public.posts
add column tags text[] not null default '{}'
constraint posts_tags_limit check (cardinality(tags) <= 10);File seed lần này có bốn bài thay vì hai. Mình thêm một bài nháp và một bài đã viết xong nhưng hẹn tới năm 2030 mới đăng, vì đó là hai loại bài mà người đọc không được thấy:
-- Bốn bài mẫu: hai bài đã đăng, một bài nháp, một bài hẹn giờ đăng trong tương lai.
insert into public.posts (slug, title, description, body_markdown, status, published_at) values
('xin-chao', 'Xin chào', 'Bài đầu tiên của blog.',
E'Đây là bài viết đầu tiên.\n\nBài viết giờ nằm trong bảng posts trên Supabase.',
'published', '2026-11-03 08:00+07'),
('vi-sao-viet-blog', 'Vì sao mình viết blog', 'Ghi lại những gì học được khi đi làm.',
E'Viết ra giúp mình hiểu kỹ hơn.\n\nVà người đi sau đỡ mất thời gian hơn.',
'published', '2026-11-04 08:00+07'),
('bai-dang-viet-do', 'Bài đang viết dở', 'Chưa xong, chưa muốn ai đọc.',
'Mới có dàn ý.', 'draft', null),
('bai-hen-gio', 'Bài hẹn giờ', 'Đã viết xong, hẹn đăng đầu năm 2030.',
'Nội dung sẽ lên lúc 8:00 ngày 1/1/2030.', 'published', '2030-01-01 08:00+07');Giờ tới script thử cách sửa nhanh. Nó dựng database với hai migration đầu, cho anon đọc thử, chạy grant all, rồi đóng vai một người lạ cầm khóa:
// Cách sửa nhanh (không nên): trang báo "permission denied" thì cấp hết quyền cho anon.
import { demBai, taoDb, voiVaiAnon } from "./gia-lap-supabase.mjs";
const db = await taoDb(2); // hai migration của bài trước: bảng posts và cột tags, chưa có RLS
await voiVaiAnon(db, "Trước khi grant, anon đọc bài", "select slug from public.posts");
await db.exec("grant all on public.posts to anon;");
console.log("\n-- đã chạy: grant all on public.posts to anon --\n");
await voiVaiAnon(db, "anon đọc bài", "select slug, status from public.posts order by id");
await voiVaiAnon(db, "anon sửa tiêu đề", "update public.posts set title = 'Blog này đã bị sửa' where slug = 'xin-chao' returning slug, title");
await voiVaiAnon(db, "anon xóa bài", "delete from public.posts where slug = 'vi-sao-viet-blog'");
console.log(`\nSố bài còn lại trong bảng: ${await demBai(db)}`);Trước khi grant, anon đọc bài: LỖI permission denied for table posts
-- đã chạy: grant all on public.posts to anon --
anon đọc bài: xin-chao | published; vi-sao-viet-blog | published; bai-dang-viet-do | draft; bai-hen-gio | published
anon sửa tiêu đề: xin-chao | Blog này đã bị sửa
anon xóa bài: 1 dòng bị ảnh hưởng
Số bài còn lại trong bảng: 3Dòng đầu là lỗi mà trang web đang gặp. Sau grant all, trang chạy được, nhưng bạn nhìn ba dòng tiếp theo. Người lạ đọc được cả bài nháp lẫn bài hẹn giờ năm 2030. Họ đổi tiêu đề bài đầu tiên thành "Blog này đã bị sửa". Họ xóa một bài, và bảng chỉ còn ba.
Không có gì bị hack ở đây. Database làm đúng những gì nó được bảo, vì grant all nghĩa là được làm tất cả.
Grant và Row Level Security khác nhau thế nào?
PostgreSQL có hai lớp kiểm soát quyền trên một bảng, và mình thấy dễ nhớ nhất là coi chúng trả lời hai câu hỏi khác nhau.
Lớp thứ nhất là grant. Nó trả lời câu hỏi vai này được làm thao tác gì trên bảng: select, insert, update hay delete. Nó không phân biệt các dòng. Đã cho select là cho đọc mọi dòng.
Lớp thứ hai là Row Level Security, viết tắt là RLS. Theo tài liệu RLS của Supabase, khi bật RLS trên một bảng, mỗi thao tác còn phải đi qua các policy, tức những điều kiện SQL được kiểm tra trên từng dòng. Policy trả lời câu hỏi vai này được đụng tới những dòng nào.
Với blog, câu trả lời mình muốn là anon chỉ được select, và chỉ trên những dòng có status = 'published' mà đã tới giờ đăng. Phần đầu là việc của grant, phần sau là việc của policy.
Bật RLS mà quên viết policy thì sao?
Trước khi làm đúng, mình thử một lỗi rất hay gặp. Bạn bật RLS vì đọc ở đâu đó rằng nên bật, cấp select cho anon, rồi chạy trang:
// Bật RLS nhưng chưa viết policy nào: anon được phép select, nhưng không thấy dòng nào.
import { demBai, taoDb, voiVaiAnon } from "./gia-lap-supabase.mjs";
const db = await taoDb(2);
await db.exec(`
grant select on public.posts to anon;
alter table public.posts enable row level security;
`);
await voiVaiAnon(db, "anon đếm số bài", "select count(*)::int as so_bai from public.posts");
console.log(`Số bài thật sự có trong bảng: ${await demBai(db)}`);anon đếm số bài: 0
Số bài thật sự có trong bảng: 4Không có lỗi nào cả. Trang chủ hiện ra bình thường, chỉ là không có bài nào, trong khi bảng có đủ bốn bài. Khi một bảng đã bật RLS mà không có policy nào cho phép, PostgreSQL mặc định từ chối mọi dòng, và với select thì "từ chối" nghĩa là coi như dòng đó không tồn tại.
Mình nhớ kỹ chuyện này vì nó dễ khiến bạn tìm lỗi sai chỗ. Trang trống thì bạn sẽ nghi câu truy vấn, nghi dữ liệu chưa được seed, nghi biến môi trường, trước khi nghĩ tới policy. Nếu trang Supabase của bạn trả về mảng rỗng mà bảng rõ ràng có dữ liệu, hãy xem policy trước tiên.
Dòng thứ hai còn cho thấy một điều nữa. Hàm demBai chạy dưới vai mặc định của PGlite, là chủ của bảng, và nó vẫn đếm được bốn bài. RLS không áp dụng cho chủ bảng và superuser. Trên Supabase cũng vậy với khóa secret (trước đây gọi là service_role): nó bỏ qua toàn bộ RLS. Vì thế khóa secret chỉ được nằm trên máy chủ, không bao giờ được mang tiền tố NEXT_PUBLIC_.
Viết policy cho người đọc thế nào?
Đây là migration thứ ba của dự án, tạo bằng npx supabase migration new bat_rls_bai_viet:
-- Người đọc (vai anon) chỉ được ĐỌC các bài đã đăng và đã tới giờ đăng.
-- Lớp 1: grant quyết định vai nào được làm thao tác gì trên bảng.
revoke all on public.posts from anon, authenticated;
grant select on public.posts to anon, authenticated;
-- Lớp 2: Row Level Security quyết định vai đó thấy những DÒNG nào.
alter table public.posts enable row level security;
create policy "Ai cũng đọc được bài đã đăng"
on public.posts for select
to anon, authenticated
using (status = 'published' and published_at <= now());Dòng revoke all đứng đầu để migration cho ra cùng một kết quả dù trước đó ai đã cấp gì. Nếu project của bạn từng chạy grant all để chữa cháy, dòng này dọn nó đi.
Policy có bốn phần. for select nói nó áp dụng cho thao tác đọc. to anon, authenticated nói nó áp dụng cho cả người chưa đăng nhập lẫn người đã đăng nhập, vì người đã đăng nhập cũng là người đọc. using (...) là điều kiện kiểm tra trên từng dòng, và dòng nào cho kết quả true thì được thấy.
Điều kiện có hai vế, và vế thứ hai dễ bị quên. status = 'published' loại bài nháp. published_at <= now() loại bài hẹn giờ, tức những bài tác giả đã bấm đăng nhưng chọn một thời điểm trong tương lai. Thiếu vế này, bài hẹn 8:00 sáng mai sẽ đọc được từ tối nay bởi bất kỳ ai gọi thẳng API, dù trang chủ chưa hiện nó.
Mình không viết policy nào cho insert, update hay delete. Đó là cố ý. Không có policy cho một thao tác nghĩa là thao tác đó bị từ chối trên mọi dòng.
Policy thật của Tech Notes gần như giống hệt. Đây là đoạn trong file 20261103141232_create_posts.sql của repo, viết ngày 3/11/2026:
alter table public.posts enable row level security;
create policy "Anyone can read published posts"
on public.posts for select
to anon, authenticated
using (status = 'published' and published_at <= now());
-- New tables are not exposed to the Data API automatically (Supabase change, enforced 2026-10-30),
-- so grant exactly what each role needs. RLS above still decides which rows they see.
revoke all on public.posts from anon, authenticated;
grant select on public.posts to anon;File thật còn bốn policy nữa dành cho tác giả, mình để dành cho phần đăng nhập.
Thử lại từng thao tác với vai anon
Mình không tin một policy cho tới khi thử nó. Script sau chạy cả ba migration, rồi đóng vai người lạ làm đủ năm việc: đọc danh sách, hỏi thẳng bài nháp theo slug, sửa, xóa và thêm bài. Sau đó nó giả định một ngày có người lỡ tay chạy lại grant all, và thử lại lần nữa:
// Chạy cả ba migration (có RLS và policy), rồi thử mọi thao tác dưới vai anon.
import { demBai, taoDb, voiVaiAnon } from "./gia-lap-supabase.mjs";
const db = await taoDb(3);
console.log(`Bảng posts có ${await demBai(db)} bài: 2 đã đăng, 1 nháp, 1 hẹn giờ năm 2030.\n`);
await voiVaiAnon(db, "anon đọc bài", "select slug from public.posts order by published_at desc");
await voiVaiAnon(db, "anon hỏi thẳng bài nháp", "select count(*)::int as so_dong from public.posts where slug = 'bai-dang-viet-do'");
await voiVaiAnon(db, "anon sửa tiêu đề", "update public.posts set title = 'Blog này đã bị sửa' where slug = 'xin-chao'");
await voiVaiAnon(db, "anon xóa bài", "delete from public.posts where slug = 'vi-sao-viet-blog'");
await voiVaiAnon(db, "anon thêm bài", "insert into public.posts (slug, title) values ('bai-la', 'Bài lạ')");
// Giả sử một ngày có người lỡ tay cấp lại hết quyền cho anon. RLS vẫn còn đó.
await db.exec("grant all on public.posts to anon;");
console.log("\n-- lỡ tay chạy: grant all on public.posts to anon --\n");
await voiVaiAnon(db, "anon đọc bài", "select slug from public.posts order by published_at desc");
await voiVaiAnon(db, "anon sửa tiêu đề", "update public.posts set title = 'Blog này đã bị sửa' where slug = 'xin-chao'");
await voiVaiAnon(db, "anon xóa bài", "delete from public.posts where slug = 'vi-sao-viet-blog'");
await voiVaiAnon(db, "anon thêm bài", "insert into public.posts (slug, title) values ('bai-la', 'Bài lạ')");
const tieuDe = await db.query("select title from public.posts where slug = 'xin-chao'");
console.log(`\nSố bài còn lại: ${await demBai(db)}. Tiêu đề bài xin-chao: ${tieuDe.rows[0].title}`);Bảng posts có 4 bài: 2 đã đăng, 1 nháp, 1 hẹn giờ năm 2030.
anon đọc bài: vi-sao-viet-blog; xin-chao
anon hỏi thẳng bài nháp: 0
anon sửa tiêu đề: LỖI permission denied for table posts
anon xóa bài: LỖI permission denied for table posts
anon thêm bài: LỖI permission denied for table posts
-- lỡ tay chạy: grant all on public.posts to anon --
anon đọc bài: vi-sao-viet-blog; xin-chao
anon sửa tiêu đề: 0 dòng bị ảnh hưởng
anon xóa bài: 0 dòng bị ảnh hưởng
anon thêm bài: LỖI new row violates row-level security policy for table "posts"
Số bài còn lại: 4. Tiêu đề bài xin-chao: Xin chàoNửa trên là trạng thái đúng. Người lạ chỉ thấy hai bài đã đăng. Hỏi thẳng bài nháp theo slug thì nhận về 0 dòng, y như bài đó không tồn tại, nên họ cũng không dò ra được blog đang có bài nháp nào. Ba thao tác ghi bị chặn ngay ở lớp grant, với lỗi "permission denied".
Nửa dưới là lý do mình muốn có cả hai lớp. Sau khi ai đó lỡ cấp lại hết quyền, lớp grant không còn chặn gì nữa, nhưng RLS vẫn đứng đó. Sửa và xóa không báo lỗi mà chỉ ảnh hưởng 0 dòng, vì không có policy nào cho anon thấy dòng để sửa hay xóa. Thêm bài thì bị từ chối thẳng. Dòng cuối xác nhận bảng vẫn đủ bốn bài và tiêu đề vẫn là "Xin chào".
Bạn so nó với output của grant-all.mjs ở đầu bài. Cùng một câu grant all, nhưng lần này không mất gì.
Trang Next.js đọc bài thế nào?
Với policy đã có, trang chỉ cần gọi Supabase bằng khóa publishable. Đây là phần lõi của hàm đọc bài trong Tech Notes, file src/lib/posts-source.ts. Mình lược bớt phần chọn ngôn ngữ và phần dữ liệu mẫu, vì chúng không liên quan tới bài này:
const { data, error } = await createPublicClient(env)
.from("posts")
.select(POST_COLUMNS)
.eq("status", "published")
.lte("published_at", new Date().toISOString())
.order("published_at", { ascending: false });
if (error) throw new Error(`Could not load posts from Supabase: ${error.message}`);createPublicClient tạo một client Supabase từ URL của project và khóa publishable, không kèm phiên đăng nhập nào, nên mọi truy vấn đều chạy dưới vai anon. POST_COLUMNS là chuỗi liệt kê các cột cần lấy.
Bạn có thể hỏi vì sao code vẫn lọc status và published_at khi policy đã lọc rồi. Mình giữ cả hai vì chúng phục vụ hai mục đích. Bộ lọc trong code nói rõ ý định của trang và giúp PostgreSQL dùng index posts_published_at_idx. Policy là lớp bảo vệ cho trường hợp ai đó quên bộ lọc, hoặc gọi thẳng API mà không qua code của mình.
Phần này mình không chạy được bằng PGlite, vì nó cần một project Supabase thật. Nhưng bạn có thể kiểm chứng gián tiếp ngay trên repo này: các script nội dung của Tech Notes đọc danh sách bài bằng đúng khóa publishable, và chúng chỉ thấy bài đã đăng. Muốn đọc bài nháp, script phải đăng nhập bằng một tài khoản tác giả trước.
Vậy tác giả viết bài bằng cách nào?
Blog giờ an toàn với người lạ. Nhưng bạn nhìn lại output của thu-rls.mjs sẽ thấy nó an toàn với tất cả mọi người, kể cả chính tác giả. Không vai nào được thêm hay sửa bài qua API. Hiện tại muốn đăng bài mới, mình phải vào Dashboard hoặc viết thêm một migration, và đó chính là thứ mình muốn thoát khỏi khi chuyển bài viết ra khỏi code.
Thứ còn thiếu là một cách để database biết "request này đến từ tác giả". Điều đó cần đăng nhập, và cần thêm policy cho phép vai authenticated ghi, nhưng chỉ khi người đăng nhập thật sự là tác giả chứ không phải bất kỳ ai tạo được tài khoản. Đó là chủ đề của bài viết tiếp theo. Bạn theo dõi cả series ở Xây blog với Next.js + Supabase, và nếu đang ôn phỏng vấn, các câu hỏi về database có trong ngân hàng câu hỏi phỏng vấn.