Use Cases (更新: 2026/6/1)

Claude Code + Supabase 实战指南:Auth、RLS、Storage 与 Edge Functions

用 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 DocsAuthRow Level SecurityEdge FunctionsStorage

新项目建议用@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 优先则差分更小,发布前检查也更清楚。

#Claude Code #Supabase #BaaS #PostgreSQL #RLS #认证
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。