Claude Code + Supabase 实战指南:Auth、RLS、Storage 与 Edge Functions
用 Claude Code 安全实现 Supabase:Auth、RLS、Storage、Edge Functions、迁移、测试和上线前审查。
Supabase 很适合快速做原型,但也很容易被“看起来能用”的代码误导。如果只是让 Claude Code “加一个登录和数据库”,它可能会生成页面,却遗漏 Row Level Security、Storage policy、迁移流程和服务端 session 处理。
Supabase 是一个 BaaS,也就是 Backend as a Service。它把 Postgres 数据库、Auth、Storage、Edge Functions 和自动生成的 API 放在一起。对初学者来说,它像是把后端常用功能托管起来;对生产项目来说,关键价值是可以用 Postgres、约束和 RLS 在数据层守住权限。
本文以 Next.js App Router 和 TypeScript 为例,整理一套适合 Claude Code 的 Supabase 工作流:需求文件、schema/RLS 审查、迁移命令、类型生成、可复制代码、测试命令、常见陷阱和发布前清单。认证细节可搭配认证实现指南,数据库建模可看数据库设计,迁移流程可看数据库迁移自动化。
先对齐官方文档
实现前先看官方资料:Supabase Docs、Auth、Row Level Security、Edge Functions、Storage。
新项目建议用@supabase/ssr处理 Next.js 的 cookie-based Auth,并使用NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY作为浏览器可见的 publishable key。旧项目可能还在用 anon key,但新文章和新实现最好让读者走新钥匙体系。
架构
示例功能是一个项目笔记:登录用户创建自己的笔记,上传私有附件,并调用 Edge Function 做通知类处理。
flowchart LR
User["Browser"] --> Next["Next.js App Router"]
Next --> SSR["@supabase/ssr client"]
SSR --> Auth["Supabase Auth"]
SSR --> DB["Postgres tables"]
SSR --> Storage["Storage bucket"]
Next --> Fn["Edge Function"]
Fn --> DB
DB --> RLS["RLS policies"]
Storage --> StorageRLS["storage.objects policies"]
安全边界不放在 React 组件里,而是放在 Postgres RLS 和 Storage policy 里。UI 可以改善体验,但不能成为唯一的权限判断。
给 Claude Code 的需求文件
先把边界写进仓库,再让 Claude Code 动手。
# docs/supabase-notes-requirements.md
## Goal
Build a Supabase-backed project notes feature in Next.js App Router.
## Stack
- Next.js App Router
- TypeScript
- @supabase/supabase-js
- @supabase/ssr
- Supabase Auth, Postgres, Storage, Edge Functions
## Data model
- project_notes table
- Each note belongs to auth.users.id through owner_id
- Public notes are readable by anyone
- Private notes are readable only by the owner
- Owners can insert, update, and delete only their own notes
## Security rules
- Never expose a secret key or service role key in browser code
- Use NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY for browser and SSR clients
- Enable RLS on every public table
- Use explicit TO anon or TO authenticated in every policy
- Storage uploads must be restricted to a user-owned folder
## Claude Code workflow
1. Create SQL migration first.
2. Review RLS policies before writing UI.
3. Generate TypeScript database types.
4. Implement Supabase clients.
5. Implement server actions and upload helper.
6. Add test or manual verification commands.
7. Return a review checklist with file paths.
第一轮只让 Claude Code 输出 SQL migration,不要同时生成 UI。Masa 的经验是,先做 UI 时最容易把owner_id从表单传入,后面再补 RLS 会很痛苦。
安装与环境变量
npm install @supabase/supabase-js @supabase/ssr zod
npm install --save-dev supabase vitest
npx supabase init
npx supabase start
.env.local只放本地真实值,不贴进提示词。
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxxxxxxxxxxxxxxxxxxx
secret key 或旧 service role key 不要放进NEXT_PUBLIC_。如果确实需要管理员权限,应放在服务端隔离,并写清为什么要绕过 RLS。
先写 migration 与 RLS
-- supabase/migrations/202606010001_create_project_notes.sql
create table if not exists public.project_notes (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id) on delete cascade,
title text not null check (char_length(title) between 1 and 120),
body text not null default '',
visibility text not null default 'private'
check (visibility in ('private', 'public')),
attachment_path text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists project_notes_owner_created_idx
on public.project_notes (owner_id, created_at desc);
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists set_project_notes_updated_at on public.project_notes;
create trigger set_project_notes_updated_at
before update on public.project_notes
for each row
execute function public.set_updated_at();
alter table public.project_notes enable row level security;
create policy "Anyone can read public notes"
on public.project_notes
for select
to anon, authenticated
using (
visibility = 'public'
or (select auth.uid()) = owner_id
);
create policy "Owners can insert notes"
on public.project_notes
for insert
to authenticated
with check ((select auth.uid()) = owner_id);
create policy "Owners can update notes"
on public.project_notes
for update
to authenticated
using ((select auth.uid()) = owner_id)
with check ((select auth.uid()) = owner_id);
create policy "Owners can delete notes"
on public.project_notes
for delete
to authenticated
using ((select auth.uid()) = owner_id);
Storage bucket 也用 policy 约束到用户自己的文件夹。
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values (
'note-attachments',
'note-attachments',
false,
5242880,
array['image/png', 'image/jpeg', 'application/pdf']
)
on conflict (id) do update
set public = excluded.public,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
create policy "Users can upload own note attachments"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'note-attachments'
and (select auth.uid())::text = (storage.foldername(name))[1]
);
create policy "Users can read own note attachments"
on storage.objects
for select
to authenticated
using (
bucket_id = 'note-attachments'
and (select auth.uid())::text = (storage.foldername(name))[1]
);
应用到本地并生成类型。
npx supabase db reset
npx supabase gen types typescript --local > src/lib/database.types.ts
npm run typecheck
Supabase client 分离
// src/lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "@/lib/database.types";
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
}
// src/lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import type { Database } from "@/lib/database.types";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
} catch {
// Server Components cannot set cookies directly.
}
},
},
},
);
}
Auth、CRUD 与上传
// app/login/actions.ts
"use server";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export async function signIn(formData: FormData) {
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { error: error.message };
redirect("/dashboard");
}
// src/features/notes/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
type CreateNoteInput = {
title: string;
body: string;
visibility?: "private" | "public";
attachmentPath?: string | null;
};
export async function createNote(input: CreateNoteInput) {
const supabase = await createClient();
const {
data: { user },
error: userError,
} = await supabase.auth.getUser();
if (userError || !user) throw new Error("Authentication required");
const { data, error } = await supabase
.from("project_notes")
.insert({
owner_id: user.id,
title: input.title,
body: input.body,
visibility: input.visibility ?? "private",
attachment_path: input.attachmentPath ?? null,
})
.select("id,title,visibility")
.single();
if (error) throw error;
revalidatePath("/dashboard");
return data;
}
// src/features/notes/upload-note-attachment.ts
"use client";
import { createClient } from "@/lib/supabase/client";
export async function uploadNoteAttachment(file: File, userId: string) {
const supabase = createClient();
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
const path = `${userId}/${crypto.randomUUID()}.${ext}`;
const { error } = await supabase.storage
.from("note-attachments")
.upload(path, file, {
cacheControl: "3600",
upsert: false,
contentType: file.type,
});
if (error) throw error;
return path;
}
Edge Function
npx supabase functions new notify-note-created
// supabase/functions/notify-note-created/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";
Deno.serve(async (req) => {
const authorization = req.headers.get("Authorization");
if (!authorization) {
return Response.json({ error: "Missing authorization" }, { status: 401 });
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: authorization } } },
);
const {
data: { user },
error: userError,
} = await supabase.auth.getUser();
if (userError || !user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
return Response.json({ ok: true, userId: user.id });
});
npx supabase functions serve notify-note-created --env-file .env.local
npx supabase functions deploy notify-note-created
3 个实用场景
| 场景 | Supabase 功能 | Claude Code 负责 | 人类重点审查 |
|---|---|---|---|
| SaaS 团队笔记 | Auth、Postgres、RLS | 表、Server Action、列表 UI | 团队边界和 owner 边界 |
| 会员资料下载 | Auth、Storage、签名 URL | 上传 UI、下载 API、审计日志 | bucket 是否私有 |
| 活动预约管理 | Postgres、Edge Functions | 预约表、通知函数、取消流程 | 重复预约、重试、退款或取消 |
常见陷阱
RLS 只开启不写 policy,会导致应用突然读不到数据;随手写using (true),又会把不该公开的行暴露出去。要分别测试匿名、登录本人、其他用户。
把 secret key 放进客户端是最严重的错误。浏览器只应看到 publishable key,管理权限必须服务端隔离。
Storage 路径不要由 UI 任意决定。使用userId/random-file这样的固定结构,并让 policy 与代码保持一致。
迁移后忘记生成database.types.ts会让 Claude Code 基于旧类型继续写代码。每次 schema 变化后都要重新生成。
Edge Function 也是公开 HTTP 入口。必须验证 Authorization,并优先使用会经过 RLS 的 Supabase client。
Claude Code 审查清单
Review only the Supabase integration.
Check RLS, explicit TO roles, publishable key usage, no secret key in client code,
owner_id derived from authenticated user, Storage paths matching policies,
generated types in sync, and Edge Functions validating Authorization.
Return findings with file paths and line numbers.
上线前手动确认:匿名用户只能读 public notes,登录用户只能改自己的 note,不能上传到其他用户文件夹,未认证请求不能调用 Edge Function。
ClaudeCodeLab 可以帮助团队审查 Supabase + Claude Code 实现,整理 RLS、迁移、CLAUDE.md和权限边界。如果要把这套流程引入团队,可以从Claude Code 培训与咨询开始。
实际试用这套流程后,收益最大的是“先让 Claude Code 审查 RLS migration,再写 UI”。Masa 的记录里,UI 优先更容易出现从表单传owner_id的问题;migration 优先则差分更小,发布前检查也更清楚。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。