首页
/ Supabase Next.js 用户认证与资料管理 Starter 实战指南:Auth、Storage 与 RLS

Supabase Next.js 用户认证与资料管理 Starter 实战指南:Auth、Storage 与 RLS

2026-09-06 19:14:01作者:滕妙奇

本文以 Supabase 官方仓库中的 nextjs-user-management 示例 为骨架,完整剖析一个"用户注册/登录 → 编辑公开资料 → 上传头像"全流程应用的工程实现。你将掌握在 Next.js App Router + Server Actions 中集成 Supabase SSR Auth 的关键调用链(服务端 Cookie 会话、邮箱确认回调、登出)、用 Row Level Security 按用户隔离资料的数据库设计,以及基于 Storage 的头像上传与访问策略,并能在本地与远程 Supabase 项目上一键跑通。

技术全景:这套 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-js v2,负责浏览器内的资料读取/更新以及实时数据;
  • 后端: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 给出完整流程:

  1. 在 Supabase Dashboard 创建项目并等待数据库就绪;
  2. 进入项目 Settings → API 页,记录 Project URLpublishable key。publishable key 是客户端密钥,允许"匿名访问"直到用户登录;用户登录后使用其个人 JWT,RLS 便可将数据按用户隔离。务必牢记 secret(service role)密钥拥有全部数据权限并绕过所有策略,只能存放在服务端,绝不能下发到浏览器;
  3. 将生产环境模板填入 URL、publishable key 与允许的 redirect 目标 URL:
cp .env.production.example .env.production
  1. 将本地 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_URLNEXT_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/ssrcreateServerClient 把 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.tomlenable_confirmations = true)。当用户点击确认邮件中的链接时,会携带 token_hashtype 两个 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_hashtype,再执行 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
}

这里的技巧是:requestsupabaseResponse 是两个独立的 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)$).*)',
  ],
}

文件中两处醒目注释值得吸收为工程经验:createServerClientgetClaims() 之间不要插入任何代码(避免竞态导致随机登出);若去掉 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 的工作流分为两步:

  1. 读资料:用 claims.sub 作为主键,从 profiles 表按 id 精确查询,并把 RLS 场景下常见的"行不存在(406/PGRST116)"作为非致命错误容忍处理(if (error && status !== 406));
  2. 写资料:直接 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";usernameunique 约束与 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_nameavatar_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_urladditional_redirect_urls 从环境变量 NEXT_SITE_URLNEXT_REDIRECT_URLS 读取,构成重定向白名单;jwt_expiry = 3600enable_refresh_token_rotation = truerefresh_token_reuse_interval = 10 共同定义 token 生命周期;enable_signup = true 放行注册;
  • [auth.email]enable_confirmations = true 强制邮箱确认(本地确认邮件由 inbucket 捕获,可直接在 Studio 里查看);otp_length = 6otp_expiry = 3600 定义邮件 OTP 参数;max_frequency = "1m0s" 限制重发频率;content_path 分别指向 supabase/auth/email/confirmation.htmlmagic-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_URLNEXT_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 已就绪)。

登录后查看全文
热门项目推荐
相关项目推荐