基于 Expo + Supabase 构建 React Native 用户管理应用:从行级安全到头像存储的完整实践指南
导读
本文围绕 examples/user-management/expo-user-management 示例项目展开,介绍如何用 Expo(React Native)快速搭建一套包含邮箱密码注册/登录、个人资料读取与更新、头像上传下载的移动端用户管理应用,并深入讲解其背后的 Supabase Auth、Postgres 行级安全(Row Level Security,RLS)与 Storage 对象存储策略。读完本文,你将掌握在移动端安全接入 Supabase 的完整链路,并能够理解为什么「客户端只用受限密钥 + 数据库强制 RLS」是这套示例安全的根本保障。
该示例完整托管在 Supabase 官方仓库中,核心文件包括应用入口 App.tsx、Supabase 客户端初始化 lib/supabase.ts、三个界面组件 components/Auth.tsx、components/Account.tsx、components/Avatar.tsx,以及可直接入库的 SQL 迁移 supabase/migrations/20240403090422_init.sql。
一、环境准备与依赖概览
1.1 前置要求
运行该示例前需要准备:
- Node.js 环境与包管理器(示例使用 npm,仓库中已提供
package.json); - Expo CLI:示例基于 Expo SDK ~55 开发,脚本通过
expo命令驱动,因此需要按 Expo 官方安装流程全局安装或使用npx expo方式调用; - 一个 Supabase 项目:示例中的数据库表结构、存储桶与授权策略默认由 Supabase 云端项目承载。
从 package.json 可以看到示例的技术栈版本组合:expo ~55.0.5、react-native 0.83.2、react 19.2.0、@supabase/supabase-js ^2(2.x 系列)、@react-native-async-storage/async-storage 2.2.0(用于会话持久化)、expo-image-picker(头像图库选取)以及 react-native-url-polyfill(补齐 React Native 环境的 URL 能力)。这些依赖共同构成了一条面向移动端的 Supabase 集成基线。
1.2 本地开发相关配置
示例目录下自带 supabase/config.toml,可用于本地 Supabase 开发环境。其中几个与本示例强相关的配置项值得注意:
[db] major_version = 15:声明数据库主版本为 Postgres 15,本地运行应与远端一致;[auth] enable_signup = true与[auth.email] enable_confirmations = false:默认允许注册且不做邮箱确认,便于本地调试;生产环境建议开启邮箱确认;[storage] file_size_limit = "50MiB":单文件上传上限为 50 MiB,超出会拒绝;[inbucket] enabled = true:本地邮箱测试服务,登录邮件可在 Inbucket Web 界面查看,实际不会真正外发;[auth.email.template.*]区块被注释保留,可按需自定义邮件模板路径。
需要说明的是,本文的注册、存储桶与策略等核心步骤同样以「Supabase 云项目 + SQL 编辑器」为准(与原文档一致),本地 config.toml 只是仓库提供的另一种可复现路径,属于锦上添花的补充。
二、从零起步:创建项目并初始化数据库
2.1 创建 Supabase 项目并获取连接凭据
在 Supabase Dashboard 中创建一个新项目,等待数据库启动完成。随后进入项目的 SQL 编辑器,找到并执行 User Management Starter 快速启动模板(也可直接执行本文第五节的完整 SQL,二者等价)。
启动模板会一次性完成三件事:
- 创建
profiles数据表并开启行级安全; - 通过触发器在用户注册时自动创建个人资料;
- 创建
avatars存储桶并配置访问策略。
2.2 获取 API URL 与客户端密钥
进入 Project Settings → API,复制以下两项:
- Project URL:形如
https://<project-ref>.supabase.co的 API 端点; - publishable key / anon key:客户端专用密钥(不同版本控制台命名略有差异,仓库此版本以 publishable key 命名,见 .env.example)。
理解这两把密钥的职责边界是接入 Supabase 的必修课:
anon/publishable密钥用于客户端(浏览器、移动端)。它允许用户登录前对数据库进行「匿名访问」,一旦用户登录成功,请求令牌会自动切换为该用户自己的登录 JWT,从而让数据库层面的行级安全策略真正生效;secret/service_role密钥拥有绕过所有安全策略的完全权限,只能保存在受信任的服务器环境中,绝不可打包进客户端或浏览器,否则任何人反编译 App 即可获得数据库全量读写能力。
2.3 配置环境变量
复制环境变量模板并填入上一步拿到的值:
cp .env.example .env
.env.example 内容如下,这正是需要填充的完整变量清单:
# Get these from your API settings
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-key
两个变量均以 EXPO_PUBLIC_ 为前缀,这是 Expo 官方约定的暴露给客户端代码的环境变量命名规范——只有带此前缀的变量才会在打包时被内联进 App。与之对应,lib/supabase.ts 中正是通过 process.env.EXPO_PUBLIC_SUPABASE_URL 与 process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY 读取这两项配置,因此在 npm start 之前必须完成 .env 填充,否则会因变量为空导致客户端初始化失败。
三、安装依赖并运行应用
3.1 安装依赖
在示例目录下执行:
npm install
该命令会依据 package.json 安装全部依赖(锁定于仓库提交的版本范围)。若使用 pnpm workspace 管理整个 monorepo,也可在仓库根目录用 workspace 方式安装。
3.2 头像选择器需要先预构建
示例的头像上传依赖 expo-image-picker,其中涉及原生相机胶卷权限模块。在 React Native 原生能力参与的流程中,直接运行 Expo Go 无法覆盖全部能力,因此首次运行前必须先执行预构建以生成本地原生工程:
npm run prebuild
package.json 中该脚本对应 expo prebuild,会生成 android/ 与 ios/ 原生目录。此后如需重新打包到真机,可再通过 npm run android / npm run ios(对应 expo run:android / expo run:ios)进行原生编译运行。
3.3 启动应用
npm start
对应脚本为 expo start --dev-client,即以开发客户端模式启动。为什么此处不是默认的 Expo Go 模式?原因正是 3.2 节提到的文件选择器原生依赖——--dev-client 会加载预构建出的原生模块,从而保证 ImagePicker 在开发阶段即可正常工作。启动后按终端提示在模拟器或真机上打开应用即可。
启动后流程立即衔接核心功能:应用先展示登录/注册界面(Auth 组件),登录成功后自动切换到资料编辑界面(Account 组件)。
四、应用源码拆解:登录、资料与头像的完整数据流
4.1 客户端初始化:会话持久化到本地存储
lib/supabase.ts 是整个 App 与 Supabase 的唯一通道:
import { createClient } from '@supabase/supabase-js'
import AsyncStorage from '@react-native-async-storage/async-storage'
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!
const supabasePublishableKey = process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
export const supabase = createClient(supabaseUrl, supabasePublishableKey, {
auth: {
storage: AsyncStorage as any, // 用 AsyncStorage 持久化会话
autoRefreshToken: true, // 自动刷新过期 access token
persistSession: true, // 重启 App 后恢复登录态
detectSessionInUrl: false, // 原生环境无 URL 重定向,关闭探测
},
})
四个 auth 选项都是移动端场景的关键开关:storage 把 JWT 与刷新令牌存进 @react-native-async-storage/async-storage;persistSession 保证用户杀掉 App 再打开仍处于登录状态;autoRefreshToken 让 SDK 在令牌临近过期时用刷新令牌自动续期;detectSessionInUrl: false 则告知 SDK 不需要监听 URL 中的会话参数(那是 Web 端 OAuth 回调的场景)。
4.2 会话状态驱动界面切换
App.tsx 采用了一个轻量但清晰的「单状态切界面」方案:
- 组件挂载后调用
supabase.auth.getClaims()读取当前 JWT 中的声明(claims),取出claims.sub(即auth.users中的用户 UUID)与claims.email; - 通过
supabase.auth.onAuthStateChange订阅登录态变化事件:每次SIGNED_IN/SIGNED_OUT等事件触发时重新getClaims(); - 渲染逻辑一行决定界面:
userId ? <Account/> : <Auth/>。
因此整套 UI 无需任何全局状态管理库,登录态即 UI 态。注意这里读取用户身份用的是 JWT claims 而非再次请求数据库,属于一次本地解密操作,开销极小;只有当真正需要读取 profiles 数据时才发起网络请求(见 Account 组件)。
4.3 登录与注册界面(Auth 组件)
components/Auth.tsx 提供邮箱 + 密码两套动作:
// 登录:校验密码并换取会话
const { error } = await supabase.auth.signInWithPassword({ email, password })
// 注册:创建用户并触发 handle_new_user 触发器
const { error } = await supabase.auth.signUp({ email, password })
signUp 成功后会返回一个会话,而服务端同时会在 auth.users 表插入新行,该行会立即触发数据库中的 on_auth_user_created 触发器,自动为该用户创建一条 profiles 记录(详见第五节 SQL)。错误统一通过 Alert.alert(error.message) 呈现,SDK 返回的错误消息已经人类可读,无需二次映射。
4.4 资料展示与编辑(Account 组件)
components/Account.tsx 接收上层传入的 userId 与 email,挂载后读取资料:
const { data, error, status } = await supabase
.from('profiles')
.select(`username, website, avatar_url`)
.eq('id', userId)
.single()
if (error && status !== 406) throw error
这里对 406 状态做了特判:当用户尚无 profile 行时 PostgREST 会返回 406,代码将其视为「正常空态」而非错误。保存时使用 upsert:
const updates = { id: userId, username, website, avatar_url, updated_at: new Date() }
const { error } = await supabase.from('profiles').upsert(updates)
upsert 同时覆盖了「首次写入」与「再次更新」两种场景,省去先查后写的分支。注意写入对象必须携带 id = userId,因为数据库侧的 RLS 更新策略要求 auth.uid() = id,用户只能写自己的行。Email 输入框被设为只读,邮箱归属 auth 系统而非 profiles,符合最小权限原则。
4.5 头像上传与下载(Avatar 组件)
components/Avatar.tsx 演示了与 Supabase Storage 交互的完整模式:
下载:通过公开策略可读的存储桶对象,无需签名 URL 即可取回:
const { data, error } = await supabase.storage.from('avatars').download(path)
const fr = new FileReader()
fr.readAsDataURL(data) // 转为 data URL 供 <Image> 显示
上传:先用 expo-image-picker 打开系统图库并允许裁剪旋转,再把图片转为 ArrayBuffer 上传:
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsMultipleSelection: false,
allowsEditing: true,
quality: 1,
exif: false, // 丢弃 EXIF 元数据以保护隐私
})
const arraybuffer = await fetch(image.uri).then((res) => res.arrayBuffer())
const fileExt = image.uri?.split('.').pop()?.toLowerCase() ?? 'jpeg'
const path = `${Date.now()}.${fileExt}` // 时间戳命名避免冲突
const { data, error: uploadError } = await supabase.storage
.from('avatars')
.upload(path, arraybuffer, { contentType: image.mimeType ?? 'image/jpeg' })
onUpload(data.path) // 把返回的文件路径回传给 Account 持久化
文件路径由时间戳加扩展名构成,天然唯一,避免多人上传同名覆盖。上传成功后将路径回传给 Account.updateProfile 写入 profiles.avatar_url,下次进入页面时即可直接 download 该路径渲染头像。这就是一条「选图 → 上传 Storage → 路径入库 → 读库取路径 → 下载渲染」的闭环。
五、安全基石:Postgres 行级安全与完整数据库脚本
原文档明确指出:该项目使用 Postgres 行级安全(RLS)实现了高水准的授权。当你在 Supabase 启动一个 Postgres 数据库时,系统会自动注入 auth schema 与若干辅助函数;用户登录后携带的 JWT 包含角色 authenticated 与用户 UUID,数据库正是依据这些信息对每行数据的读写施加细粒度控制。
仓库内的 supabase/migrations/20240403090422_init.sql 是官方 Quickstart SQL 的等价物,且比原文档中的「精简版」更完整(多出 full_name 字段、自动建档触发器与更新策略)。下面按段解析其安全设计。
5.1 profiles 表结构与 RLS 开启
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)
);
alter table profiles
enable row level security;
设计要点:
id既作主键又外键引用auth.users.id,把业务资料与认证用户强绑定,同时继承 auth 用户的级联生命周期;username带unique约束,数据库层保证昵称唯一,杜绝并发注册下的竞态;username_length检查约束强制用户名至少 3 个字符,把校验下沉到数据库,任何客户端都绕不过;alter table ... enable row level security之后,未显式授予策略的操作默认全部拒绝——这是 Postgres 的默认拒绝模型,也是安全的前提。
5.2 三条 RLS 策略
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 (auth.uid() = id);
create policy "Users can update own profile." on profiles
for update using (auth.uid() = id);
逐条解读:
| 策略 | 语句 | 判定表达式 | 语义 |
|---|---|---|---|
| 公开可读 | select |
using (true) |
所有人的资料都可被读取(个人主页、列表页场景) |
| 仅自建 | insert |
with check (auth.uid() = id) |
只能插入 id 等于自己 UUID 的行,无法伪造他人资料 |
| 仅自改 | update |
using (auth.uid() = id) |
只能更新属于自己的行,using 限定可被修改的行范围 |
auth.uid() 是 Supabase 注入的辅助函数,其返回值来自当前 JWT 的 sub 声明。using 决定「哪些行可操作」,with check 决定「写入后的新行是否允许存在」,二者结合构成了对 select / insert / update 的完整约束。原文档特别强调:anon 密钥在用户未登录时以 anon 角色访问,此时 auth.uid() 为空,上述 insert/update 策略自然全部拒绝——安全不是靠客户端藏起按钮,而是靠数据库强制执行。
5.3 注册自动建档触发器
迁移文件比原文档精简版多出的关键部分,是用户注册时的自动建档机制:
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();
security definer 使触发器以函数所有者(超级用户)权限执行,从而能安全写入开启了 RLS 的 profiles 表;new.id 直接取新注册用户的 UUID,new.raw_user_meta_data 则把注册时携带的元数据(如昵称)带入 profile。这也解释了 4.4 节中「为何新用户首次加载时 profile 已存在」——是数据库触发器而非客户端代码完成了建档。
5.4 头像存储桶与访问策略
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']));
-- 任何人可上传(注意上传方需持有合法 JWT 才算 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');
这里呈现了 Storage 与数据库 RLS 的同构安全模型:select 公开(所有人可看图),insert 放开到 avatars 桶但以 with check 锁定桶名,update 额外用 auth.uid() = owner 把修改权收敛到文件属主。Storage 的文件元数据同样存在数据库(storage.objects),因此可以复用同一套 RLS 语法做对象级授权。
六、补充机制:Realtime 与本地开发注意事项
6.1 打开 Realtime 发布通道
原文档 SQL 末尾通过创建/清空 supabase_realtime 发布并追加 profiles 表,为后续接入实时订阅(例如好友资料变化即时刷新)预留通道:
begin;
drop publication if exists supabase_realtime;
create publication supabase_realtime;
commit;
alter publication supabase_realtime add table profiles;
从源码结构看,当前示例 UI 尚未消费 realtime 事件,但保留该发布意味着你可以通过 supabase.channel('profiles').on('postgres_changes', ...) 一行接入,不必再回头补迁移。而在仓库本地配置 supabase/config.toml 中同样有 [realtime] enabled = true 与之呼应。
6.2 本地跑通整套迁移
若希望脱离云端、完全本地验证,仓库为示例附带了同款 SQL 迁移 supabase/migrations/20240403090422_init.sql 与 supabase/config.toml。结合根目录的 Supabase CLI 工作流(docker/dev/docker-compose.dev.yml、docker/README.md 等提供自托管基础设施),可在本地完成 supabase start → 迁移应用 → npm start 的全链路验证。此路径下邮箱验证可借助 [inbucket] 本地收信界面完成,避免污染真实邮箱。作为对照,仓库还提供了同一「用户管理」主题下的 Web/其他移动端实现,例如 examples/user-management/nextjs-user-management 与 examples/user-management/flutter-user-management,其共享同一套 profiles 表结构与 RLS 策略,方便跨端横向对照学习。
七、常见问题与安全红线小结
- 启动即报错提示 URL 为空:多为未执行
cp .env.example .env,或变量未以EXPO_PUBLIC_前缀命名导致未被打包内联,检查.env后需重启npm start; npm start无法唤起系统相册:未执行npm run prebuild并用 Expo Go 直接运行导致,原生模块需经expo prebuild与--dev-client模式加载;- 用户登录后看不到自己的资料:检查
profiles是否有该用户的行——正常由handle_new_user触发器自动创建;若在关闭触发器的情况下手动用 SQL 编辑器插行,编辑器会话以 service 角色执行,不受 RLS 限制; - 上传失败返回 403/row-level security violation:Storage
insert需要当前请求携带合法 JWT(即已登录的authenticated角色),未登录的匿名请求会被策略拒绝,属预期行为; - 务必区分三类密钥:客户端只能用
anon/publishablekey;service_role/secretkey 拥有绕过全部 RLS 的完全权限,一旦泄露等同数据库裸奔,只允许出现在后端服务环境。
总而言之,这个示例最大的教学价值在于其「薄客户端 + 厚数据库」的授权范式:客户端只保留 anon 密钥与会话 JWT,全部数据边界由 RLS 策略、外键约束、检查约束与触发器在 Postgres 内部强制闭环。你可以在阅读 数据库迁移文件 与四个 核心组件 源码的基础上,将此模式迁移到自己的业务表,例如给 posts、comments 等表设计各自的 auth.uid() = author_id 策略,即可快速扩展为任意「用户拥有自己数据」的移动应用。
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