Supabase Next.js 用户认证与资料管理 Starter 实战指南:Auth、Storage 与 RLS
技术全景:这套 Starter 在做什么
该示例解决的是 Web 应用最常见的一个需求组合:用户可以用邮箱密码注册或登录,随后维护一份"公开资料"(username、full name、website、头像),头像以图片文件形式存储并由系统管理访问权限。
在 Supabase 官方仓库中的实际代码(package.json)锁定了以下技术栈:
- 前端:Next.js(App Router + React 19)+ Tailwind CSS v4,通过
next dev --turbopack启动开发服务器; - 服务端认证:
@supabase/ssr(cookie 驱动的 SSR 会话),从 Server Component 与 Server Action 两侧使用; - 客户端数据:
@supabase/supabase-jsv2,负责浏览器内的资料读取/更新以及实时数据; - 后端:Supabase 托管的 Postgres(自带 REST API、Auth、Storage),或通过 Supabase CLI 拉起本地开发栈。
三张组件图可以概括项目职责划分:
- 页面与路由:
app/login(登录/注册表单)、app/account(受保护的资料页)、app/auth/confirm(邮箱确认回调)、app/auth/signout(登出路由); - Supabase 客户端封装:
lib/supabase/server.ts(服务端 Cookie 客户端)、lib/supabase/client.ts(浏览器客户端)、lib/supabase/proxy.ts(全局会话刷新); - 数据库与配置:
supabase/migrations/(profiles表、RLS 策略、触发器、avatars 存储桶)、supabase/config.toml(本地栈配置)。
两种运行方式
本地运行(推荐先跑通)
按 README 中 Run locally 一节执行即可:
# 1. 安装依赖(需 Node.js 20+,npx 随 npm 分发)
npm install
# 2. 启动本地 Supabase 栈(Postgres、Auth、Storage、Studio 一次拉起,
# 并自动执行 supabase/migrations/ 下的迁移)
npx supabase start
# 3. 配置环境变量:.env.example 中的默认值已与本地栈匹配
# (API URL http://127.0.0.1:54321 + demo publishable key)
cp .env.example .env.local
# 4. 启动 Next.js 开发服务器
npm run dev
启动后浏览器访问 http://localhost:3000。注意 npx supabase start 结束时终端会打印本地的 API URL 与密钥,若你的本地端口与 .env.example 默认值不同,需要手动同步。
远程 Supabase 项目
README 的 Using a remote Supabase project 给出完整流程:
- 在 Supabase Dashboard 创建项目并等待数据库就绪;
- 进入项目 Settings → API 页,记录 Project URL 与 publishable key。publishable key 是客户端密钥,允许"匿名访问"直到用户登录;用户登录后使用其个人 JWT,RLS 便可将数据按用户隔离。务必牢记 secret(service role)密钥拥有全部数据权限并绕过所有策略,只能存放在服务端,绝不能下发到浏览器;
- 将生产环境模板填入 URL、publishable key 与允许的 redirect 目标 URL:
cp .env.production.example .env.production
- 将本地 checkout 关联到远程项目,并推送配置与数据库结构:
# 关联项目(把 <your-project-ref> 换成项目 ref)
SUPABASE_ENV=production npx supabase@latest link --project-ref <your-project-ref>
# 推送 config.toml 中的站点 URL、redirect URL 等设置
SUPABASE_ENV=production npx supabase@latest config push
# 推送 migrations 中的数据库结构
SUPABASE_ENV=production npx supabase@latest db push
一次点击部署到 Vercel
README 也提供了 Instant deploy 的 Vercel 部署入口:引导创建 Supabase 账户与项目,安装 Supabase 集成后所有相关环境变量自动就绪,部署完成即可直接使用。若项目启用了 Vercel Preview Branching,每个分支可对应独立的 Supabase 项目,配置好 NEXT_PUBLIC_SUPABASE_URL 与 NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY 两组 Preview 环境变量后,提交 PR 即触发预览构建,可在合入前安全验证数据库迁移或服务配置变更。
认证调用链:从登录表单到邮箱确认
Server Action 中的登录与注册
登录页 app/login/page.tsx 是一个纯表单:它不绑定任何提交处理函数,而是用 formAction={login} 与 formAction={signup} 分别指向两个 Server Action。这是 Next.js App Router 中"表单 + Server Action"的标准姿势——表单数据直接以 FormData 传入服务端函数,全程无需客户端状态与 fetch。
两个 Server Action 定义在 app/login/actions.ts,逻辑几乎对称:
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
export async function login(formData: FormData) {
const supabase = await createClient()
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signInWithPassword(data)
if (error) {
redirect('/error')
}
revalidatePath('/', 'layout')
redirect('/account')
}
signup 与之唯一的不同是调用 supabase.auth.signUp(data)。两点工程细节值得注意:
- 代码注释明确提示:这里的类型断言只是为了方便,生产环境必须在服务端对输入做真正的校验(例如用 zod 等方案),避免将脏数据直接送入认证接口;
- 认证失败统一
redirect('/error'),跳转到一个错误提示页(app/error/page.tsx),而不是在客户端弹错——这是 Server Action 时代常见的错误处理分流策略。
服务端 Cookie 客户端:会话的载体
lib/supabase/server.ts 是整套认证的核心封装,通过 @supabase/ssr 的 createServerClient 把 Next.js 的 cookie store 桥接到 Supabase 会话:
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet, _headers) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have proxy refreshing user sessions.
}
},
},
}
)
}
理解这段代码的关键在于 cookie 的"双向读写契约":getAll() 让服务端读取当前请求携带的会话 cookie;setAll() 则把 Supabase 下发的会话 cookie(access/refresh token 等)写回响应。由于在 Server Component 里 cookies().set() 是被禁止的,代码用 try/catch 静默吞掉写入失败——这正是 @supabase/ssr 文档强调的典型模式:Server Component 中不应尝试写 cookie,会话刷新统一交给下文的 proxy 处理。浏览器端则简单得多,lib/supabase/client.ts 只做一件事:
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
// Create a supabase client on the browser with project's credentials
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
)
}
邮箱确认回调:token_hash 校验
本地栈默认开启了邮箱确认(见下文 config.toml 中 enable_confirmations = true)。当用户点击确认邮件中的链接时,会携带 token_hash 与 type 两个 query 参数回到应用,由 app/auth/confirm/route.ts 处理:
import { type EmailOtpType } from '@supabase/supabase-js'
import { type NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const token_hash = searchParams.get('token_hash')
const type = searchParams.get('type') as EmailOtpType | null
const next = '/account'
// Create redirect link without the secret token
const redirectTo = request.nextUrl.clone()
redirectTo.pathname = next
redirectTo.searchParams.delete('token_hash')
redirectTo.searchParams.delete('type')
if (token_hash && type) {
const supabase = await createClient()
const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
})
if (!error) {
redirectTo.searchParams.delete('next')
return NextResponse.redirect(redirectTo)
}
}
// return the user to an error page with some instructions
redirectTo.pathname = '/error'
return NextResponse.redirect(redirectTo)
}
该路由的核心安全设计是:先克隆原始 URL 并剥离 token_hash、type,再执行 verifyOtp——成功则 302 跳到 /account,失败则跳到 /error。这样敏感的一次性 token 永远不会残留在浏览器的地址栏或被日志记录。token_hash 的 OTP 语义(类型、有效期)由 Supabase Auth 在签发链接时决定。
登出路由
app/auth/signout/route.ts 是一个接收 POST 的路由处理器,被账号表单中 <form action="/auth/signout" method="post"> 直接调用:
export async function POST(req: NextRequest) {
const supabase = await createClient()
// Check if a user's logged in
const { data: claimsData } = await supabase.auth.getClaims()
if (claimsData?.claims) {
await supabase.auth.signOut()
}
revalidatePath('/', 'layout')
return NextResponse.redirect(new URL('/login', req.url), {
status: 302,
})
}
用 POST 而非 GET 承载登出动作符合 CSRF 防护的惯例;登出前先用 getClaims() 确认存在会话,避免无谓的远端调用。登出完成后清理缓存并重定向回 /login。
全站会话刷新代理
@supabase/ssr 官方模板要求设置一个中间件/代理来在每次请求时刷新 access token,否则用户的会话可能"随机掉线"。在本示例中它被实现为一个可跨运行时复用的代理函数 lib/supabase/proxy.ts:
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
Object.entries(headers).forEach(([key, value]) =>
supabaseResponse.headers.set(key, value)
)
},
},
}
)
// Do not run code between createServerClient and
// supabase.auth.getClaims(). A simple mistake could make it very hard to debug
// issues with users being randomly logged out.
// IMPORTANT: If you remove getClaims() and you use server-side rendering
// with the Supabase client, your users may be randomly logged out.
await supabase.auth.getClaims()
return supabaseResponse
}
这里的技巧是:request 与 supabaseResponse 是两个独立的 cookie 容器,setAll 把新 cookie 同时写入 request(供当前请求链继续使用)与响应(下发到浏览器)。而 proxy.ts 仅负责把 updateSession 注册到路由表,并用 matcher 排除静态资源与图片,确保仅对真实页面请求执行会话刷新:
export async function proxy(request: NextRequest) {
// update user's auth session
return await updateSession(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
文件中两处醒目注释值得吸收为工程经验:createServerClient 与 getClaims() 之间不要插入任何代码(避免竞态导致随机登出);若去掉 getClaims() 又同时用了 SSR,用户同样可能被随机登出——刷新会话与渲染逻辑必须解耦。
受保护的资料页与 profiles 表的读写
app/account/page.tsx 是受保护的账号页。它作为 Server Component 用服务端客户端解析当前会话 claims,再把 claims 下传给客户端表单组件:
import AccountForm from './account-form'
import { createClient } from '@/lib/supabase/server'
export default async function Account() {
const supabase = await createClient()
const { data: claimsData } = await supabase.auth.getClaims()
return <AccountForm claims={claimsData?.claims ?? null} />
}
值得说明的是:这里没有用完整的 getUser() 拉取整个用户对象,而是用轻量的 getClaims() 从本地 JWT 中直接读出 sub(用户 UUID)与 email。其依据是 JWT 的 payload 中本就内嵌这些字段,无需额外的网络往返——这也是前面会话刷新与登出逻辑复用同一 API 的原因。
客户端表单 app/account/account-form.tsx 的工作流分为两步:
- 读资料:用
claims.sub作为主键,从profiles表按id精确查询,并把 RLS 场景下常见的"行不存在(406/PGRST116)"作为非致命错误容忍处理(if (error && status !== 406)); - 写资料:直接
supabase.from('profiles').upsert({...})。因为profiles.id外键引用auth.users.id,而新用户注册时触发器已自动建好行,这里的 upsert 既覆盖"已存在则更新",也兜底"万一没有则插入"的边界。写操作不需要任何后端路由,数据经 PostgREST 落到 Postgres,行级安全策略(见下节)在数据库层完成最终授权。
Row Level Security:把授权下沉到数据库
为什么需要 RLS
这套架构的安全模型建立在 Postgres 的 Row Level Security 之上。Supabase 项目自带 auth schema 与若干辅助函数;用户登录后拿到的是携带 role = authenticated 与自身 UUID 的 JWT。因此数据库可以在每一条 SQL 语句上以 auth.uid() 识别当前用户,实现"按行"的细粒度授权——即使某条绕过应用的请求直接打到 REST API,也无法越权读写他人数据。README 将这一节作为全篇的授权核心专门讲述。
profiles 表、策略与触发器
supabase/migrations/20221017024722_init.sql 是全部数据库结构的唯一来源,按顺序做四件事:
① 建表并约束:
create table profiles (
id uuid references auth.users not null primary key,
updated_at timestamp with time zone,
username text unique,
full_name text,
avatar_url text,
website text,
constraint username_length check (char_length(username) >= 3)
);
id 直接引用 auth.users 且为主键,从数据模型上保证了"一个 Auth 用户至多一条 profile";username 的 unique 约束与 username_length(不少于 3 字符)由数据库层兜底,即便应用漏校验也无法写入非法数据。
② 开启 RLS 并定义三张策略:
alter table profiles
enable row level security;
create policy "Public profiles are viewable by everyone." on profiles
for select using (true);
create policy "Users can insert their own profile." on profiles
for insert with check ((select auth.uid()) = id);
create policy "Users can update own profile." on profiles
for update using ((select auth.uid()) = id);
策略语义一望即知:select 对所有角色开放(公开资料可被任何人浏览);insert 仅当被写入行的 id 等于当前 auth.uid() 时放行;update 仅允许修改自己的行。注意策略组合中缺省了 delete——即任何用户(包括本人)都无法删除 profile 行,这在个人资料场景中是合理取舍。
③ 注册触发器自动建档:
create function public.handle_new_user()
returns trigger as $$
begin
insert into public.profiles (id, full_name, avatar_url)
values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
handle_new_user 被声明为 security definer,因此它拥有函数属主的权限,可在插入 auth.users 后立即为同 UUID 创建 profile——把 raw_user_meta_data 中的 full_name、avatar_url(通常来自 OAuth 登录)预填进资料行。这正是前面 account-form 敢于对"行不存在"宽容处理的原因之一:正常情况下行在注册那一刻就已存在。
Storage 桶与对象策略
同一迁移接着创建 avatars 桶并配置访问控制:
insert into storage.buckets (id, name)
values ('avatars', 'avatars');
create policy "Avatar images are publicly accessible." on storage.objects
for select using (bucket_id = 'avatars' and storage.allow_any_operation(array['object.get_authenticated_info', 'object.get_authenticated']));
create policy "Anyone can upload an avatar." on storage.objects
for insert with check (bucket_id = 'avatars');
create policy "Anyone can update their own avatar." on storage.objects
for update using ( auth.uid() = owner ) with check (bucket_id = 'avatars');
三条对象策略与资料表策略形成对照:select 允许匿名下载头像(公开浏览需要);insert 允许任何已登录用户向 avatars 桶上传;update 则要求对象 owner 与 auth.uid() 一致——也就是说上传后只有本人能覆盖自己的头像文件。README 在此强调,实际生产应结合业务收紧策略(例如校验 MIME、限制上传者身份),示例策略只是最小可运行基线。
本地 Auth 与存储相关配置
supabase/config.toml 中与本节直接相关的关键项包括:
[api]:schemas = ["public"]决定哪些 schema 暴露成 REST 端点;max_rows = 1000限制单次查询返回行数,防止意外/恶意的大查询打满数据库;[auth]:site_url与additional_redirect_urls从环境变量NEXT_SITE_URL、NEXT_REDIRECT_URLS读取,构成重定向白名单;jwt_expiry = 3600、enable_refresh_token_rotation = true、refresh_token_reuse_interval = 10共同定义 token 生命周期;enable_signup = true放行注册;[auth.email]:enable_confirmations = true强制邮箱确认(本地确认邮件由 inbucket 捕获,可直接在 Studio 里查看);otp_length = 6、otp_expiry = 3600定义邮件 OTP 参数;max_frequency = "1m0s"限制重发频率;content_path分别指向supabase/auth/email/confirmation.html与magic-link.html两个邮件模板;[storage.buckets.avatars]:public = false说明该桶并非公开 CDN,访问必须经过 RLS 策略;allowed_mime_types = ["image/*"]在存储层限制只能上传图片。
本地开发时邮件并不会真正外发,而是被 [inbucket](端口 54324)截获,供开发者在网页界面查验"本应发出"的邮件内容。
Storage 头像上传:客户端实现细节
头像组件 app/account/avatar.tsx 是一个 'use client' 组件,展示完整的"上传 + 回显"闭环:
上传侧,从 input[type=file] 取到首个文件后构造一个带随机后缀的对象名,避免同用户重复上传互相覆盖:
const file = event.target.files[0]
const fileExt = file.name.split('.').pop()
const filePath = `${uid}-${Math.random()}.${fileExt}`
const { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file)
if (uploadError) {
throw uploadError
}
onUpload(filePath)
上传成功后通过 onUpload(filePath) 把对象路径交回 account-form,由其把路径写入 profiles.avatar_url 并触发 updateProfile 持久化。这里存的不是公开 URL 而是 Storage key,意味着"能否取到图"由数据库中的 URL 与 RLS 策略共同决定。
回显侧,先判断 URL 存在,再从 avatars 桶下载对象并用浏览器 API 生成临时地址:
async function downloadImage(path: string) {
const { data, error } = await supabase.storage.from('avatars').download(path)
if (error) throw error
const url = URL.createObjectURL(data)
setAvatarUrl(url)
}
下载走的是 Supabase 认证过的 Storage API(而非静态 CDN 链接),因此与上面的 select 策略闭环:匿名用户拿不到下载凭据,登录用户则可正常取回本人或其他人的公开头像。这种 download + URL.createObjectURL 的组合,是"私有桶 + 认证访问"下最典型的客户端取图方式。
运行前提与环境变量
按 README 的 Run locally 要求,本地开发需要 Node.js 20+(npx 随 npm 分发)。根环境变量约定如下:
NEXT_PUBLIC_SUPABASE_URL:Supabase API 地址,本地为http://127.0.0.1:54321;NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY:客户端 publishable key(NEXT_PUBLIC_前缀确保变量对浏览器可见,但 publishable key 本身设计为可公开暴露的);NEXT_SITE_URL、NEXT_REDIRECT_URLS:写入config.toml的站点 URL 与重定向白名单,本地默认对应http://localhost:3000。
注意:本地与生产使用两套 env 模板——.env.example → .env.local(开发),.env.production.example → .env.production(远程项目),其差异恰是 npx supabase config push 依赖的 SUPABASE_ENV 取值。生产环境切勿把 secret/service_role 密钥写进 .env.local,否则会被打包进浏览器产物。
小结:从示例到生产的关键启示
这套 Starter 虽小,却浓缩了 Supabase + Next.js 现代全栈应用的骨架级模式,可直接迁移到真实项目:
- 认证全链路:Server Actions 承载登录/注册表单 → 服务端 cookie 客户端写入会话 → 邮箱确认路由校验 OTP → 代理层每次请求刷新 token → 登出走 POST 路由,每一步都有清晰的"谁在何时写 cookie"的边界;
- 授权在数据库层:
profiles表开启 RLS,策略按auth.uid()与行id对齐;触发器用security definer在注册瞬间建档,应用层永远不需要处理"建行"的职责; - 对象存储与数据表协同:Storage 桶/对象策略与表策略各自独立又互相呼应,
avatar_url只存对象 key,取图时经认证 API 下载; - 环境变量双轨制:本地
.env.local与生产.env.production分离,配合SUPABASE_ENV定向操作 link/config push/db push。
对这套示例做二次开发的下一步自然延伸包括:将 signInWithPassword 替换为 OAuth 提供商、把 getClaims 换成更重的 getUser 以获取最新用户元数据、为头像上传增加前端尺寸压缩与 MIME 预检,以及把 profiles.updated_at 接入 select 中的实时订阅(supabase-js 已就绪)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00