Thay Supabase Auth bằng Clerk: Đăng nhập bảo mật trong 30 phút

17/05/2026 · P T P · Chung

Thay thế Supabase Auth bằng Clerk: thiết lập đăng nhập bảo mật trong 30 phút

Supabase mạnh. Auth tích hợp nhanh. Nhưng khi sản phẩm lớn hơn, nhu cầu auth đổi: social login tốt hơn, quản lý session chặt hơn, giao diện đăng nhập sẵn có, bảo mật nhiều lớp, tổ chức/team, MFA, webhook rõ ràng. Lúc đó, Clerk thành lựa chọn đáng cân nhắc.

Bài này chỉ cách thay Supabase Auth bằng Clerk trong app dùng Supabase làm database. Mục tiêu: vẫn dùng Supabase Postgres, RLS, API, nhưng bỏ phần xác thực Supabase Auth. Clerk lo đăng nhập, đăng ký, session, user management. Supabase lo dữ liệu.

Kịch bản phù hợp: app Next.js, frontend cần đăng nhập nhanh, backend cần xác minh user chắc, database vẫn nằm trong Supabase.


Vì sao thay Supabase Auth bằng Clerk?

Supabase Auth tốt, nhưng không luôn đủ

Supabase Auth hợp khi cần auth sát database, đơn giản, cùng hệ sinh thái. Nhưng có điểm khiến nhiều team đổi:

– UI đăng nhập phải tự làm nhiều.
– Quản lý session phía frontend cần thêm code.
– Social login, MFA, organization, invitation cần cấu hình kỹ.
– User metadata và lifecycle cần xử lý thêm.
– Trải nghiệm dashboard quản lý user không mạnh bằng Clerk.

Clerk mạnh ở lớp identity

Clerk tập trung vào authentication và user experience. Điểm mạnh:

Component đăng nhập sẵn: , , .
Session bảo mật: token rotation, device/session management.
Social login nhanh: Google, GitHub, Microsoft, Apple.
MFA, magic link, passkey: bật trong dashboard.
Organizations: hợp SaaS B2B.
Webhook user lifecycle: sync user sang database riêng.
Middleware bảo vệ route: gọn, rõ.

Ý tưởng chính: Clerk xác thực người dùng, Supabase lưu dữ liệu.


Kiến trúc sau khi thay

Luồng mới:

1. User đăng nhập bằng Clerk.
2. App lấy userId từ Clerk session.
3. App gọi Supabase bằng server client.
4. Dữ liệu trong Supabase gắn với clerk_user_id.
5. RLS hoặc backend policy dùng clerk_user_id để kiểm soát quyền.

Bảng ví dụ:

create table profiles (
  id uuid primary key default gen_random_uuid(),
  clerk_user_id text unique not null,
  email text,
  full_name text,
  created_at timestamptz default now()
);

Supabase không còn là nguồn identity chính. auth.users không cần dùng cho user mới. Nguồn truth: Clerk.


Chuẩn bị trước khi migration

Biến môi trường cần có

Trong .env.local:

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx
CLERK_SECRET_KEY=sk_test_xxx

NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co SUPABASE_SERVICE_ROLE_KEY=xxx NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx

Không đưa SUPABASE_SERVICE_ROLE_KEY ra client. Key này bỏ qua RLS. Chỉ dùng server.

Cài package

npm install @clerk/nextjs @supabase/supabase-js

Nếu đang dùng Supabase Auth package, chưa cần xóa ngay. Chuyển từng route trước.


Bước 1: Cấu hình Clerk trong Next.js

Trong app/layout.tsx:

import { ClerkProvider } from "@clerk/nextjs";

export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <ClerkProvider> <html lang="vi"> <body>{children}</body> </html> </ClerkProvider> ); }

Thêm middleware bảo vệ route.

Tạo middleware.ts:

import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

const isProtectedRoute = createRouteMatcher([ "/dashboard(.*)", "/settings(.*)", "/api/private(.*)", ]);

export default clerkMiddleware((auth, req) => { if (isProtectedRoute(req)) auth().protect(); });

export const config = { matcher: ["/((?!.\..|_next).)", "/", "/(api|trpc)(.)"], };

Kết quả: route quan trọng cần login. Chưa login sẽ bị Clerk chặn.


Bước 2: Tạo trang đăng nhập, đăng ký

Tạo app/sign-in/[[...sign-in]]/page.tsx:

import { SignIn } from "@clerk/nextjs";

export default function Page() { return <SignIn />; }

Tạo app/sign-up/[[...sign-up]]/page.tsx:

import { SignUp } from "@clerk/nextjs";

export default function Page() { return <SignUp />; }

Trong Clerk Dashboard, cấu hình URL:

Sign-in URL: /sign-in
Sign-up URL: /sign-up
After sign-in URL: /dashboard
After sign-up URL: /dashboard

Thêm nút user:

import { UserButton } from "@clerk/nextjs";

export function Header() { return ( <header> <UserButton /> </header> ); }

Vậy là có đăng nhập, đăng ký, logout, quản lý tài khoản cơ bản.


Bước 3: Dùng Clerk user trong server code

Trong server component hoặc route handler:

import { auth, currentUser } from "@clerk/nextjs/server";

export default async function DashboardPage() { const { userId } = auth(); const user = await currentUser();

if (!userId) return null;

return ( <div> <p>User ID: {userId}</p> <p>Email: {user?.emailAddresses[0]?.emailAddress}</p> </div> ); }

userId là định danh chính. Lưu vào Supabase dưới cột clerk_user_id.


Bước 4: Kết nối Supabase ở server

Tạo lib/supabase/server.ts:

import { createClient } from "@supabase/supabase-js";

export function createSupabaseAdminClient() { return createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! ); }

Dùng trong API route:

import { auth } from "@clerk/nextjs/server";
import { createSupabaseAdminClient } from "@/lib/supabase/server";

export async function GET() { const { userId } = auth();

if (!userId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); }

const supabase = createSupabaseAdminClient();

const { data, error } = await supabase .from("projects") .select("*") .eq("clerk_user_id", userId);

if (error) { return Response.json({ error: error.message }, { status: 500 }); }

return Response.json({ data }); }

Mấu chốt: vì dùng service role, phải tự filter bằng userId. Không filter, lộ dữ liệu.


Bước 5: Đồng bộ user Clerk sang Supabase

Có hai cách.

Cách nhanh: tạo profile khi user vào dashboard

import { auth, currentUser } from "@clerk/nextjs/server";
import { createSupabaseAdminClient } from "@/lib/supabase/server";

export async function ensureProfile() { const { userId } = auth(); const user = await currentUser();

if (!userId || !user) throw new Error("Unauthorized");

const email = user.emailAddresses[0]?.emailAddress ?? null; const fullName = ${user.firstName ?? ""} ${user.lastName ?? ""}.trim();

const supabase = createSupabaseAdminClient();

await supabase.from("profiles").upsert( { clerk_user_id: userId, email, full_name: fullName, }, { onConflict: "clerk_user_id" } ); }

Gọi hàm này trong layout dashboard hoặc server action.

Cách chuẩn: dùng webhook

Webhook bền hơn. User tạo, sửa, xóa đều sync.

Tạo endpoint app/api/webhooks/clerk/route.ts:

import { Webhook } from "svix";
import { headers } from "next/headers";
import { createSupabaseAdminClient } from "@/lib/supabase/server";

export async function POST(req: Request) { const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;

if (!WEBHOOK_SECRET) { return Response.json({ error: "Missing secret" }, { status: 500 }); }

const headerPayload = headers(); const svix_id = headerPayload.get("svix-id"); const svix_timestamp = headerPayload.get("svix-timestamp"); const svix_signature = headerPayload.get("svix-signature");

if (!svix_id || !svix_timestamp || !svix_signature) { return Response.json({ error: "Missing headers" }, { status: 400 }); }

const payload = await req.text();

let evt: any;

try { const wh = new Webhook(WEBHOOK_SECRET); evt = wh.verify(payload, { "svix-id": svix_id, "svix-timestamp": svix_timestamp, "svix-signature": svix_signature, }); } catch { return Response.json({ error: "Invalid signature" }, { status: 400 }); }

const supabase = createSupabaseAdminClient();

if (evt.type === "user.created" || evt.type === "user.updated") { const user = evt.data; const email = user.email_addresses?.[0]?.email_address ?? null;

await supabase.from("profiles").upsert( { clerk_user_id: user.id, email, full_name: ${user.first_name ?? ""} ${user.last_name ?? ""}.trim(), }, { onConflict: "clerk_user_id" } ); }

if (evt.type === "user.deleted") { await supabase .from("profiles") .delete() .eq("clerk_user_id", evt.data.id); }

return Response.json({ ok: true }); }

Cài thêm:

npm install svix

Trong Clerk Dashboard, thêm webhook URL:

https://your-domain.com/api/webhooks/clerk

Chọn event:

user.created
user.updated
user.deleted


Bước 6: Chuyển bảng dữ liệu sang clerk_user_id

Nếu bảng cũ đang dùng user_id uuid trỏ tới auth.users, cần thêm cột mới:

alter table projects
add column clerk_user_id text;

Sau đó migration dữ liệu. Nếu bạn có mapping email giữa Supabase Auth và Clerk:

update projects p
set clerk_user_id = pr.clerk_user_id
from profiles pr
where p.owner_email = pr.email;

Tùy schema thực tế. Sau khi kiểm tra xong:

alter table projects
alter column clerk_user_id set not null;

Tạo index:

create index projects_clerk_user_id_idx
on projects (clerk_user_id);

Index giúp query theo user nhanh hơn.


Bảo mật cần nhớ

Không tin dữ liệu từ client

Client gửi clerk_user_id lên? Bỏ. Server lấy userId từ auth() rồi gắn vào record.

const { userId } = auth();

await supabase.from("projects").insert({ name, clerk_user_id: userId, });

Service role rất nguy hiểm nếu dùng sai

SUPABASE_SERVICE_ROLE_KEY bỏ qua RLS. Chỉ dùng trong server route, server action, cron, webhook. Không prefix bằng NEXT_PUBLIC_.

RLS với Clerk cần thiết kế riêng

Supabase RLS mặc định hay dùng auth.uid(). Khi bỏ Supabase Auth, auth.uid() không còn ý nghĩa với Clerk token nếu chưa cấu hình JWT integration.

Có hai hướng:

Backend-enforced security: dùng service role, mọi query qua server, luôn filter bằng userId.
JWT integration: cấu hình Clerk phát JWT cho Supabase, rồi RLS đọc claim.

Cách 1 nhanh trong 30 phút. Cách 2 mạnh hơn nếu client cần query trực tiếp Supabase.


Checklist 30 phút

0-5 phút

– Tạo app Clerk.
– Copy key vào .env.local.
– Cài @clerk/nextjs.

5-10 phút

– Bọc app bằng ClerkProvider.
– Thêm middleware.ts.
– Tạo /sign-in, /sign-up.

10-20 phút

– Tạo profiles.
– Tạo Supabase admin client.
– Dùng auth() lấy userId.
– Query dữ liệu theo clerk_user_id.

20-30 phút

– Thêm ensureProfile() hoặc webhook.
– Chuyển route private sang Clerk.
– Test login, logout, truy cập dashboard, query dữ liệu.


Lỗi thường gặp

Login xong nhưng vẫn bị đá ra

Kiểm tra Clerk Dashboard URL:

/sign-in
/sign-up
/dashboard

Sai URL gây redirect vòng.

API trả Unauthorized

Route handler phải chạy server-side và gọi:

const { userId } = auth();

Nếu gọi từ nơi không có request context, userId rỗng.

Dữ liệu user khác bị lộ

Thiếu .eq("clerk_user_id", userId). Sửa ngay. Nếu dùng service role, lỗi này nghiêm trọng.

Webhook không chạy

Kiểm tra:

– URL public, không phải localhost trừ khi dùng tunnel.
CLERK_WEBHOOK_SECRET đúng.
– Header svix-* đủ.
– Event đã chọn trong Clerk Dashboard.


Kết luận thực tế

Thay Supabase Auth bằng Clerk không có nghĩa bỏ Supabase. Cách tốt: Clerk làm identity, Supabase làm database. Trong 30 phút, bạn có thể có login bảo mật, UI đẹp, session ổn, social login sẵn, route protection rõ.

Điểm cần cẩn thận: phân quyền dữ liệu. Nếu dùng SUPABASE_SERVICE_ROLE_KEY, mọi query phải đi qua server và luôn filter theo userId từ Clerk. Với app nhỏ và vừa, cách này nhanh, rõ, đủ an toàn nếu code kỷ luật. Với app lớn, nhiều client query trực tiếp, hãy đầu tư JWT integration và RLS dựa trên claim.

Migration tốt nhất làm từng bước: thêm Clerk, sync profile, chuyển route, chuyển bảng sang clerk_user_id, rồi tắt dần Supabase Auth. Không cần đập đi xây lại. Chuyển nhỏ, test chắc, tránh lộ dữ liệu.

#auth #bang #clerk #supabase #thay
Chia sẻ:
← Trước
Migrate Supabase sang Neon PostgreSQL không downtime 2026

Bài viết tương tự

Bình luận

Chưa có bình luận. Hãy là người đầu tiên!