Supabase Auth Next.js: đăng nhập tác giả và phân quyền cho blog

Supabase Auth Next.js cho blog một tác giả: vì sao kiểm tra đã đăng nhập là chưa đủ, và vì sao vai trò tác giả phải nằm trong app_metadata.

Dùng Supabase Auth với Next.js thế nào để chỉ tác giả được viết bài? Bạn cần policy trong database chỉ cho vai trò tác giả được ghi, và code Next.js kiểm tra vai trò đó trước khi hiện trang quản trị. Bạn có nghĩ kiểm tra "đã đăng nhập" là đủ không? Cùng mình thử nhé!

Blog đang an toàn tới mức không ai viết được bài

Ở bài trước, mình bật Row Level Security cho bảng posts của blog-mini, dự án blog nhỏ mà series này xây dần. Kết quả là người lạ cầm khóa publishable chỉ đọc được bài đã đăng. Nhưng không vai nào được thêm hay sửa bài qua API, kể cả mình. Muốn đăng bài mới, mình phải vào Dashboard của Supabase.

Thứ còn thiếu là cách để database biết một request đến từ tác giả. Supabase Auth lo phần đăng nhập. Khi một người đăng nhập thành công, họ nhận một JWT, tức một chuỗi có chữ ký chứa thông tin về họ. Mọi request sau đó mang theo JWT này, và database chạy câu SQL dưới vai authenticated thay cho vai anon.

Phần khó nằm ở câu hỏi tiếp theo. Policy nên kiểm tra điều gì trong JWT đó? Mình thử ba cách, và hai cách đầu sai theo những kiểu rất dễ mắc.

Thử ba bộ policy trên máy bằng gì?

Mình vẫn dùng PGlite như bài trước, tức một bản PostgreSQL thật chạy trong Node, nên bạn không cần Docker hay project Supabase để làm theo. Thư mục của bài này tiếp tục từ bài trước. Ba file migration cũ và seed.sql giữ nguyên, bạn chép lại từ bài về Row Level Security và bài về migration. File seed.sql thêm bốn bài mẫu, gồm hai bài đã đăng, một bài nháp và một bài hẹn giờ.

Cấu trúc thư mục
05-dang-nhap-tac-gia/
├── package.json                  # chỉ có @electric-sql/pglite 0.3.14
├── supabase/
│   ├── seed.sql                  # giữ nguyên
│   └── migrations/
│       ├── 20261110080000_tao_bang_bai_viet.sql   # giữ nguyên
│       ├── 20261110090000_them_the_bai_viet.sql   # giữ nguyên
│       ├── 20261111080000_bat_rls_bai_viet.sql    # giữ nguyên
│       └── 20261112080000_quyen_tac_gia.sql       # MỚI
├── policy-thu/
│   ├── a-ai-dang-nhap-cung-ghi.sql                # MỚI, cách sai thứ nhất
│   └── b-user-metadata.sql                        # MỚI, cách sai thứ hai
└── scripts/
    ├── gia-lap-supabase.mjs                       # viết lại
    └── thu-quyen.mjs                              # MỚI

PGlite không có sẵn những thứ Supabase thêm vào PostgreSQL, nên file gia-lap-supabase.mjs tự tạo chúng. Bài trước nó chỉ tạo hai vai. Bài này nó tạo thêm hai hàm auth.jwt() và auth.uid(), và một hàm chayVoi để chạy câu SQL như thể nó đến từ một người cụ thể.

scripts/gia-lap-supabase.mjs
// 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 có sẵn hai vai anon, authenticated và hai hàm auth.jwt(), auth.uid(); ở đây mình tự tạo.
import { readdirSync, readFileSync } from "node:fs";
import { PGlite } from "@electric-sql/pglite";

// Trên Supabase, mỗi request mang một JWT. Lớp API giải mã nó, đặt nội dung (claims) vào thiết lập
// request.jwt.claims của phiên, rồi mới chạy câu SQL. auth.jwt() chỉ đọc lại thiết lập đó.
const GIA_LAP_AUTH = `
  create role anon nologin;
  create role authenticated nologin;
  create schema auth;
  grant usage on schema auth to anon, authenticated;
  create function auth.jwt() returns jsonb language sql stable as $$
    select coalesce(nullif(current_setting('request.jwt.claims', true), ''), '{}')::jsonb $$;
  create function auth.uid() returns uuid language sql stable as $$
    select nullif(auth.jwt() ->> 'sub', '')::uuid $$;
`;

// soMigration: chạy bao nhiêu file migration đầu tiên (theo thứ tự tên), rồi chạy seed.sql.
// sqlThem: một file SQL chạy thêm sau cùng, dùng để thử một bộ policy chưa đưa vào migration.
export async function taoDb(soMigration, sqlThem) {
  const db = new PGlite();
  await db.exec(GIA_LAP_AUTH);
  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"));
  if (sqlThem) await db.exec(readFileSync(sqlThem, "utf8"));
  return db;
}

// Chạy một câu SQL như thể nó đến từ một người cụ thể. claims = null là người chưa đăng nhập (vai anon);
// có claims là người đã đăng nhập (vai authenticated) với đúng nội dung JWT đó.
// Trả về một chuỗi ngắn: kết quả, số dòng bị ảnh hưởng, hoặc lỗi của Postgres.
export async function chayVoi(db, claims, sql) {
  await db.query("select set_config('request.jwt.claims', $1, false)", [claims ? JSON.stringify(claims) : ""]);
  await db.exec(`set role ${claims ? "authenticated" : "anon"}`);
  try {
    const kq = await db.query(sql);
    if (kq.rows.length > 0) return kq.rows.map((r) => Object.values(r).join(" | ")).join("; ");
    return `${kq.affectedRows ?? 0} dòng`;
  } catch (error) {
    return `LỖI ${error.message}`;
  } finally {
    await db.exec("reset role");
  }
}

Bản giả lập này bỏ qua một việc quan trọng mà Supabase thật có làm, đó là kiểm tra chữ ký của JWT. Ở đây mình tự viết nội dung JWT và database tin luôn. Như vậy là đủ để thử policy, vì policy chỉ đọc nội dung, nhưng bạn đừng hiểu rằng ngoài đời ai cũng tự viết được JWT.

Script thử nghiệm cho bốn người làm cùng ba việc với từng bộ policy. Mỗi người đếm số bài mình nhìn thấy, sửa tiêu đề bài xin-chao, rồi thêm một bài mới. Bảng có bốn bài, nên ai thấy 2 là chỉ thấy bài đã đăng, ai thấy 4 là thấy cả bài nháp và bài hẹn giờ.

scripts/thu-quyen.mjs
// Thử ba bộ policy với bốn người, mỗi người làm ba việc: đếm bài nhìn thấy, sửa một tiêu đề, thêm một bài.
import { chayVoi, taoDb } from "./gia-lap-supabase.mjs";

// Nội dung JWT (claims) của từng người. Supabase tự tạo phần này khi người dùng đăng nhập.
const NGUOI = [
  ["người lạ, chưa đăng nhập", null],
  ["khách vừa tự đăng ký", { sub: "00000000-0000-0000-0000-000000000002", role: "authenticated", app_metadata: {}, user_metadata: {} }],
  ["khách tự ghi role=author vào user_metadata", { sub: "00000000-0000-0000-0000-000000000003", role: "authenticated", app_metadata: {}, user_metadata: { role: "author" } }],
  ["tác giả (app_metadata.role=author)", { sub: "00000000-0000-0000-0000-000000000001", role: "authenticated", app_metadata: { role: "author" }, user_metadata: {} }],
];

const BO_POLICY = [
  ["A. Cứ đăng nhập là được ghi", 3, "policy-thu/a-ai-dang-nhap-cung-ghi.sql"],
  ["B. Kiểm tra user_metadata", 3, "policy-thu/b-user-metadata.sql"],
  ["C. Kiểm tra app_metadata (migration thứ tư)", 4, undefined],
];

for (const [ten, soMigration, sqlThem] of BO_POLICY) {
  console.log(`=== ${ten} ===`);
  for (const [nguoi, claims] of NGUOI) {
    const db = await taoDb(soMigration, sqlThem); // mỗi người một database mới, để không ai thấy thay đổi của ai
    const thay = await chayVoi(db, claims, "select count(*)::int from public.posts");
    const sua = await chayVoi(db, claims, "update public.posts set title = 'Đã bị sửa' where slug = 'xin-chao'");
    const them = await chayVoi(db, claims, "insert into public.posts (slug, title) values ('bai-moi', 'Bài mới')");
    console.log(`${nguoi}\n    thấy ${thay} bài | sửa: ${sua} | thêm: ${them}`);
  }
  console.log();
}

Bạn chạy npm install một lần rồi node scripts/thu-quyen.mjs. Mình sẽ đi qua kết quả của từng bộ policy.

Cho mọi người đã đăng nhập được ghi thì sao?

Đây là cách mình thấy nhiều nhất trong các bài hướng dẫn nhanh. Lý lẽ nghe hợp lý, vì blog chỉ có một tác giả, nên ai đăng nhập được thì chính là tác giả.

policy-thu/a-ai-dang-nhap-cung-ghi.sql
-- Cách A (SAI): cứ đăng nhập là được ghi.
grant insert, update, delete on public.posts to authenticated;

create policy "Người đăng nhập đọc được mọi bài"
on public.posts for select
to authenticated
using (true);

create policy "Người đăng nhập thêm được bài"
on public.posts for insert
to authenticated
with check (true);

create policy "Người đăng nhập sửa được bài"
on public.posts for update
to authenticated
using (true)
with check (true);
node scripts/thu-quyen.mjs (phần A)
=== A. Cứ đăng nhập là được ghi ===
người lạ, chưa đăng nhập
    thấy 2 bài | sửa: LỖI permission denied for table posts | thêm: LỖI permission denied for table posts
khách vừa tự đăng ký
    thấy 4 bài | sửa: 1 dòng | thêm: 1 dòng
khách tự ghi role=author vào user_metadata
    thấy 4 bài | sửa: 1 dòng | thêm: 1 dòng
tác giả (app_metadata.role=author)
    thấy 4 bài | sửa: 1 dòng | thêm: 1 dòng

Dòng đáng nhìn là "khách vừa tự đăng ký". Người này thấy cả 4 bài, sửa được tiêu đề và thêm được bài. Lý lẽ ban đầu sai ở chỗ "đăng nhập được" không đồng nghĩa với "là tác giả".

Supabase Auth có sẵn API đăng ký tài khoản, và nó chỉ cần khóa publishable, thứ nằm công khai trong mã JavaScript của trang. Nếu project của bạn còn bật mục "Allow new users to sign up" trong phần cấu hình Auth, bất kỳ ai cũng tự tạo được một tài khoản rồi đăng nhập. Blog của bạn không có trang đăng ký cũng không thay đổi được điều đó, vì người ta gọi thẳng API.

Tắt mục cho phép đăng ký là việc nên làm với một blog một tác giả. Nhưng mình không muốn sự an toàn của bài viết phụ thuộc vào một công tắc trong Dashboard mà ai đó có thể bật lại. Policy nên tự nó đúng.

Ghi vai trò vào user_metadata có an toàn không?

Bước tiếp theo tự nhiên là gắn cho tài khoản một vai trò, rồi để policy kiểm tra vai trò đó. Mỗi tài khoản Supabase có hai ngăn dữ liệu đi kèm, là user_metadata và app_metadata, và cả hai đều xuất hiện trong JWT. Cái tên user_metadata nghe đúng là chỗ để thông tin về người dùng, nên rất dễ chọn nó.

policy-thu/b-user-metadata.sql
-- Cách B (SAI): kiểm tra vai trò trong user_metadata, phần mà người dùng tự sửa được.
grant insert, update, delete on public.posts to authenticated;

create policy "Tác giả đọc được mọi bài"
on public.posts for select
to authenticated
using ((select auth.jwt() -> 'user_metadata' ->> 'role') = 'author');

create policy "Tác giả thêm được bài"
on public.posts for insert
to authenticated
with check ((select auth.jwt() -> 'user_metadata' ->> 'role') = 'author');

create policy "Tác giả sửa được bài"
on public.posts for update
to authenticated
using ((select auth.jwt() -> 'user_metadata' ->> 'role') = 'author')
with check ((select auth.jwt() -> 'user_metadata' ->> 'role') = 'author');
node scripts/thu-quyen.mjs (phần B)
=== B. Kiểm tra user_metadata ===
người lạ, chưa đăng nhập
    thấy 2 bài | sửa: LỖI permission denied for table posts | thêm: LỖI permission denied for table posts
khách vừa tự đăng ký
    thấy 2 bài | sửa: 0 dòng | thêm: LỖI new row violates row-level security policy for table "posts"
khách tự ghi role=author vào user_metadata
    thấy 4 bài | sửa: 1 dòng | thêm: 1 dòng
tác giả (app_metadata.role=author)
    thấy 2 bài | sửa: 0 dòng | thêm: LỖI new row violates row-level security policy for table "posts"

Khách vừa đăng ký giờ đã bị chặn. Nhưng người thứ ba thì không, vì họ tự ghi role: "author" vào user_metadata của mình. Họ làm được vậy vì đó đúng là mục đích của ngăn này. Tài liệu về Row Level Security của Supabase viết rằng user_metadata có thể được chính người dùng đã đăng nhập cập nhật, và rằng một policy dựa vào nó có thể gây ra vấn đề bảo mật. Cũng tài liệu đó nói app_metadata thì người dùng không cập nhật được, nên là chỗ tốt để lưu dữ liệu phân quyền.

Dòng cuối của kết quả cũng đáng để ý. Tác giả thật, người có vai trò ở app_metadata, lại bị từ chối. Bộ policy này vừa cho nhầm người vào, vừa chặn nhầm người ra.

Policy đúng cho tác giả trông thế nào?

Migration thứ tư của blog-mini giống hệt cách B, chỉ đổi user_metadata thành app_metadata và thêm policy cho việc xóa. Mình tạo nó bằng npx supabase migration new quyen_tac_gia.

supabase/migrations/20261112080000_quyen_tac_gia.sql
-- Tác giả (người đăng nhập có app_metadata.role = 'author') được đọc mọi bài, thêm, sửa và xóa bài.
-- app_metadata chỉ sửa được từ phía quản trị (SQL, Dashboard), người dùng không tự sửa được.

-- Lớp 1: vai authenticated được phép ghi vào bảng.
grant insert, update, delete on public.posts to authenticated;

-- Lớp 2: nhưng chỉ những dòng mà policy cho phép, và policy chỉ nhận tác giả.
create policy "Tác giả đọc được mọi bài"
on public.posts for select
to authenticated
using ((select auth.jwt() -> 'app_metadata' ->> 'role') = 'author');

create policy "Tác giả thêm được bài"
on public.posts for insert
to authenticated
with check ((select auth.jwt() -> 'app_metadata' ->> 'role') = 'author');

create policy "Tác giả sửa được bài"
on public.posts for update
to authenticated
using ((select auth.jwt() -> 'app_metadata' ->> 'role') = 'author')
with check ((select auth.jwt() -> 'app_metadata' ->> 'role') = 'author');

create policy "Tác giả xóa được bài"
on public.posts for delete
to authenticated
using ((select auth.jwt() -> 'app_metadata' ->> 'role') = 'author');

Policy cho update có cả using lẫn with check, và hai phần này làm hai việc khác nhau. using quyết định những dòng nào được chọn để sửa. with check kiểm tra dòng sau khi sửa. Với điều kiện chỉ nhìn vào JWT như ở đây thì hai phần giống nhau. Khi điều kiện nhìn vào dữ liệu của dòng, ví dụ "chỉ sửa bài của chính mình", thiếu with check sẽ cho phép một người sửa bài của mình thành bài của người khác.

Policy đọc dành cho tác giả không thay thế policy đọc của bài trước. Hai policy cùng thao tác được nối với nhau bằng "hoặc", nên một người đã đăng nhập thấy một dòng khi dòng đó đã đăng, hoặc khi người đó là tác giả.

node scripts/thu-quyen.mjs (phần C)
=== C. Kiểm tra app_metadata (migration thứ tư) ===
người lạ, chưa đăng nhập
    thấy 2 bài | sửa: LỖI permission denied for table posts | thêm: LỖI permission denied for table posts
khách vừa tự đăng ký
    thấy 2 bài | sửa: 0 dòng | thêm: LỖI new row violates row-level security policy for table "posts"
khách tự ghi role=author vào user_metadata
    thấy 2 bài | sửa: 0 dòng | thêm: LỖI new row violates row-level security policy for table "posts"
tác giả (app_metadata.role=author)
    thấy 4 bài | sửa: 1 dòng | thêm: 1 dòng

Giờ chỉ dòng cuối cùng ghi được. Bạn cũng nên để ý hai kiểu từ chối khác nhau trong kết quả. Người lạ nhận "permission denied" vì vai anon không có grant để ghi.

Hai người khách thì có grant, nên câu update chạy mà không lỗi, chỉ là nó sửa 0 dòng vì policy không cho họ chọn dòng nào. Câu insert thì báo lỗi rõ ràng. Nếu code của bạn chỉ kiểm tra "có lỗi không" sau một câu update, nó sẽ báo thành công trong khi không có gì được lưu.

Vậy ai ghi được vào app_metadata? Chỉ phía quản trị. Với Tech Notes, mình tạo tài khoản trong Dashboard rồi chạy đúng một câu trong SQL Editor, và câu này nằm trong tài liệu nội bộ của repo:

Cấp vai trò tác giả (chạy trong SQL Editor của Supabase)
update auth.users
set raw_app_meta_data = raw_app_meta_data || '{"role":"author"}'
where email = 'email-cua-tac-gia@example.com';

Có một điều cần biết sau khi chạy câu này. JWT được cấp lúc đăng nhập và mang theo app_metadata của thời điểm đó. Tài liệu của Supabase nhắc rằng JWT không phải lúc nào cũng mới. Vì vậy sau khi cấp hoặc thu hồi vai trò, người dùng phải đăng nhập lại, hoặc chờ token được làm mới, thì thay đổi mới có hiệu lực.

Policy thật của Tech Notes nằm trong file 20261103141232_create_posts.sql và giống bản trên, cộng thêm một điều kiện ở policy thêm bài là author_id = (select auth.uid()), để một tác giả không thể tạo bài đứng tên người khác.

Phía Next.js cần làm những gì?

Database đã tự bảo vệ được. Phần Next.js lo ba việc là giữ phiên đăng nhập trong cookie, chặn người không phải tác giả vào trang quản trị, và kiểm tra lại ở mỗi thao tác ghi. Phần này cần một project Supabase thật nên mình không chạy được bằng PGlite. Các đoạn dưới đây là code đang chạy của Tech Notes, mình trích nguyên văn.

Việc đầu tiên là một hàm trả lời câu hỏi "nội dung JWT này có phải của tác giả không". Nó chỉ đọc app_metadata, đúng như policy:

src/lib/author.ts (Tech Notes)
type Claims = { sub?: string; email?: string; app_metadata?: { [key: string]: unknown } | null } | null | undefined;

/**
 * Authorship comes only from app_metadata, which users cannot edit. user_metadata is ignored on
 * purpose: it is writable by the user and would let anyone promote themselves.
 */
export function isAuthorClaims(claims: Claims): claims is { sub: string; email?: string; app_metadata: { role: "author" } } {
  return typeof claims?.sub === "string" && claims.app_metadata?.role === "author";
}

Việc thứ hai là lấy nội dung JWT một cách đáng tin. Gói @supabase/ssr tạo một client đọc phiên đăng nhập từ cookie của request. Trên client đó có ba hàm nghe na ná nhau, và chọn nhầm là có lỗ hổng.

Hướng dẫn Auth cho Next.js của Supabase viết rằng luôn dùng supabase.auth.getClaims() để bảo vệ trang và dữ liệu, và không bao giờ tin getSession() trong code phía server. Lý do là getSession() chỉ đọc cookie mà không kiểm tra lại, trong khi ai cũng tự đặt được cookie trong trình duyệt của mình. getClaims() thì kiểm tra chữ ký của token ở mỗi lần gọi.

src/lib/auth.ts (Tech Notes)
/** Verifies the session JWT (getClaims) and returns the author, or null for anyone else. */
export async function getAuthor() {
  if (!getSupabaseEnv()) return null;
  const supabase = await createServerSupabase();
  const { data, error } = await supabase.auth.getClaims();
  const claims = error ? null : data?.claims;
  if (!isAuthorClaims(claims)) return null;
  return { supabase, userId: claims.sub, email: claims.email ?? "" };
}

/** For admin pages and Server Actions: every entry point checks again, the proxy is not enough. */
export async function requireAuthor() {
  const author = await getAuthor();
  if (!author) redirect(LOGIN_PATH);
  return author;
}

Hàm createServerSupabase là phần tạo client từ cookie, viết theo đúng hướng dẫn trên, nên mình không chép lại ở đây.

Việc thứ ba là chặn từ cửa. Next.js 16 có file proxy.ts chạy trước mọi request, trước đây file này tên là middleware.ts. Trong Tech Notes, mọi đường dẫn bắt đầu bằng /admin đi qua một hàm làm hai việc. Nó gọi getClaims() để làm mới token sắp hết hạn, và nó chuyển người không phải tác giả về trang đăng nhập:

src/proxy.ts (Tech Notes, phần kiểm tra)
const { data } = await supabase.auth.getClaims();
const isLoginPage = request.nextUrl.pathname === LOGIN_PATH;

if (!isLoginPage && !isAuthorClaims(data?.claims)) {
  const redirect = NextResponse.redirect(new URL(LOGIN_PATH, request.url));
  // Keep any refreshed/cleared auth cookies on the redirect response.
  for (const cookie of response.cookies.getAll()) redirect.cookies.set(cookie);
  redirect.headers.set("cache-control", "private, no-store");
  return redirect;
}

Bạn có thể hỏi vì sao còn cần requireAuthor() khi proxy đã chặn rồi. Mình giữ cả hai vì proxy chỉ che các trang. Một Server Action là một endpoint gọi được trực tiếp bằng một request POST, và nó không nên dựa vào việc người gọi đã đi qua trang nào. Vì vậy dòng đầu tiên của mọi action ghi dữ liệu trong Tech Notes là const { supabase } = await requireAuthor();.

Cuối cùng là action đăng nhập. Có hai chi tiết nhỏ trong đó mà mình nghĩ đáng chép:

src/app/admin/actions.ts (Tech Notes, action đăng nhập)
const supabase = await createServerSupabase();
const { error } = await supabase.auth.signInWithPassword({ email, password });
// One generic message for every failure so the form does not reveal which accounts exist.
if (error) return { error: "Email hoặc mật khẩu không đúng." };

const { data } = await supabase.auth.getClaims();
if (!isAuthorClaims(data?.claims)) {
  await supabase.auth.signOut();
  return { error: "Tài khoản này chưa có quyền tác giả." };
}

redirect("/admin");

Chi tiết thứ nhất là mọi lỗi đăng nhập dùng chung một thông báo. Nếu form trả lời "email không tồn tại" cho trường hợp này và "sai mật khẩu" cho trường hợp kia, người lạ dò được email nào có tài khoản. Chi tiết thứ hai là một tài khoản đăng nhập đúng mật khẩu nhưng không phải tác giả sẽ bị đăng xuất ngay, thay vì được giữ một phiên vô dụng.

Mỗi lượt xem lại hỏi database một lần

Tác giả giờ đã đăng nhập và viết bài qua API được, còn người lạ thì không, kể cả khi họ tự đăng ký tài khoản hay tự phong vai trò cho mình.

Nhưng blog-mini vẫn còn một chỗ mình chưa hài lòng, và nó nằm ở phía người đọc. Trang chủ và trang bài viết đang gọi Supabase ở mỗi lượt xem. Một bài viết đã đăng thì gần như không đổi, vậy mà một nghìn người đọc là một nghìn lần truy vấn giống hệt nhau, người đọc phải chờ truy vấn đó, và gói miễn phí thì có giới hạn. Mình muốn trang được dựng sẵn như trang tĩnh, nhưng vẫn cập nhật khi tác giả sửa bài. Đó là việc của các bài viết sau.

Bạn theo dõi cả series ở Xây blog với Next.js + Supabase. Nếu đang ôn phỏng vấn, các câu hỏi về xác thực và database có trong ngân hàng câu hỏi phỏng vấn.

Nhận bài tiếp theo qua email.

Một email mỗi tuần, chỉ khi có điều đáng đọc. Không spam.