创建客户端
这是所有操作的前提,在服务端管理登录态,我们需要专门的 @supabase/ssr package。与在客户端进行登录相比,服务端登录更加安全,不会受到 XSS 攻击。以使用 nextjs 框架为例,首先创建 ServerClient:
import { createServerClient } from "@supabase/ssr"
import { cookies } from "next/headers"
async function getSupabaseServerClient() {
const cookieStore = await cookies()
const supabase = createServerClient(process.env.SUPABASE_URL!, process.env.SUPABASE_PUBLISH_KEY!, {
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach((cookie) => {
cookieStore.set(cookie.name, cookie.value, cookie.options)
})
}
}
})
}
区别于客户端使用的 client,ServerClient 会将登录后的状态写入 cookie,也会从 cookie 读取登录态。因此我们必须设置 cookies option,说明要如何读写cookie。
supabase 使用JWT管理用户的登录,这是一种无状态的管理方案,登录期间的会话(session)全部记录在cookie中,不会落库。
注册
用户注册使用signup函数。supabase 提供了多种注册方式,比如基本的email password注册、电话注册,也支持第三方OAuth注册。本文聚焦于email password注册方式:
const supabase = await getSupabaseServerClient()
const {data, error} = await supabase.auth.signUp({
email: email,
password: password,
options: {
// 邮件认证后重定向到指定页面
emailRedirectTo: confirmRedirectUrl,
// 自定义数据
data: {
name: name,
username: username,
avatarUrl: avatarUrl,
}
}
})
除了email,password 我们通常还需要定义一些option。邮箱注册后通常需要用户验证邮箱,supabase 会往email地址发一封邮件,用户点击邮件中的链接完成认证,emailRedirectTo 指定了认证后的重定向地址。data是业务自定义数据,落库保存在metadata中。
supabase 关于重定向链接有一个白名单,你首先需要把重定向地址添加到白名单中,用户才能正常跳转
supabase 在 auth schema 中定义了一系列与认证(authentication)授权(authorization)相关的表,其中 user 表是核心,记录了用户最核心的数据。你data中的自定义数据+supabase默认的一些数据,一起保存在 user 表的 raw_user_meta_data 字段。
关于为什么要提供自定义数据,因为除了supabase 提供的user表外,服务在 public schema 下一般也会维护一份用户的业务数据表(比如:profile),这里的 data 就是 profile 关心的数据,需要在之后插入 profile。profile 通常还会定义一个 uuid 字段作为 auth user id 的外键。
注册成功后,signup 会返回user,下面是部分字段:
{
"user": {
"id": "9b6dca09-1c45-4bfd-9fa4-586ac7035217",
"role": "authenticated",
"email": "testname@example.com",
"confirmation_sent_at": "2026-03-28T11:05:43.05879826Z",
"app_metadata": {
"provider": "email",
},
"user_metadata": {
"avatarUrl": "https://example.com/avatar.png",
"email": "testname@example.com",
"email_verified": false,
"name": "test-name",
"username": "test-username"
},
"identities": [
{
"identity_id": "92e06de4-3307-4611-a5b1-47f5ee3bac3e",
"identity_data": {
"avatarUrl": "https://example.com/avatar.png",
"email": "testname@example.com",
"name": "test-name",
"username": "test-username"
},
}
],
"created_at": "2026-03-28T11:05:43.046718Z",
"updated_at": "2026-03-28T11:05:43.06988Z",
},
"session": null
},
- user_metadata:我们提供的自定义数据
- confirmation_sent_at:认证邮件的发送时间
- email_verified:是否完成认证
刚完成注册的用户,还没有session。等我们登录后,这个字段会被填充。
登录
在用户提交登录表单后,我们在action中处理登录逻辑,使用signin函数:
const supabase = await getSupabaseServerClient()
const {data, error} = await supabase.auth.signInWithPassword({
email: email,
password: password,
})
登录成功后,会返回 user 和本次会话(session),下面是一些关键字段:
{
"user": {
// 用户信息同上
},
"session": {
"access_token": "<header>.<payload>.<signature>",
"token_type": "bearer",
"expires_in": 3600,
"expires_at": 1774709832,
"refresh_token": "becdqegprjbm",
"user": {
// 用户信息同上
},
}
}
session 用来维护当前登录状态,除了 signin 返回,我们还可以通过 getSession 获得
- access_token:最关键, 以 JWT 格式记录了用户身份;
- expires_in:记录了过期时间,默认 1h;
- refresh_token:用于当过期时间临近时,刷新整个 token。supabase 规定 refresh_token 只能用一次,所以每次刷新会同时更新 access_token 和 refresh_token;
当用户首次登录成功,如果服务有另外维护用户的业务数据(profile),我们需要从返回的 user_metadata 中取出之前存入的业务信息,插入 profile:
const {data, error} = await supabase.auth.signInWithPassword(
email: email,
password: password,
})
const authUserUUID = data?.user?.id
const userMetadata = data?.user?.user_metadata
const newUser = new User({
name: userMetadata?.name,
username: userMetadata?.username,
email: email,
avatarUrl: userMetadata?.avatarUrl,
authUserUUID: authUserUUID,
})
// 尝试创建profile
const id = tryCreateProfile(newUser)
if (id !== null) {
// 登录完成,重定向到新页面
redirect("xxx")
}
function tryCreateProfile(user) {
// upsert 插入用户,如果已经存在则忽略
const supabase = await getSupabaseServerClient()
const { data: inserted, error: insertError } = await supabase
.from("profile")
.upsert(newUser, { onConflict: "auth_user_uuid", ignoreDuplicates: true })
.select("id")
.maybeSingle()
if (inserted?.id) {
// 若插入成功,返回id
return inserted.id
}
// 如果之前已经存在,则通过select查询返回id
const { data: existed, error: queryError } = await supabase
.from("profile")
.select("id")
.eq("auth_user_uuid", user.authUserUUID)
.maybeSingle()
if (existed?.id) {
return existed.id
}
}
登录成功后,supabase ServerClient 会将 session 做 base64 编码,并利用你创建 ServerClient 时提供的 cookie setAll 写入 response cookie。默认名称:sb-<projectId>-auth-token 。之后客户端发送的请求,会携带这份cookie。
access_token
登录成功后,supabase 会下发 JWT 令牌 access_token,它是session中最关键的字段,用于请求或访问数据库时鉴权。请求鉴权通过 getClaims 进行,如果验签通过,它返回解码后的access_token:
const supabase = await getSupabaseServerClient()
const { data, error } = await supabase.auth.getClaims()
console.log("access_token header", data.header)
console.log("access_token header", data.claims)
console.log("access_token header", data.signature)
- header 中声明了 JWT 使用的签名算法,常用的有 HS256,RS256,ES256。HS256 是对称加密,另外两个是非对称加密,其中 ES256 的长度 比RS256 更短。
- claims 是鉴权的核心数据
- signature 是签名,supabase 用它来校验token的完整性
我们可以将这三部分做base64编码,再与session中的access_token比较,会发现是一样的:
Buffer.from(JSON.stringify(data.header)).toString("base64url")
Buffer.from(JSON.stringify(data.claims)).toString("base64url")
Buffer.from(data.signature).toString("base64url")
header
--- header ---
{
"alg": "ES256",
"kid": "6c2bee6b-d281-474f-a361-4c2c46bbcfa3",
"typ": "JWT"
}
--- base64url ---
eyJhbGciOiJFUzI1NiIsImtpZCI6IjZjMmJlZ.....
- alg: 使用的签名算法;
- kid:指定公钥id,因为ES256是非对称加密,鉴权时会先从 /.well-known/jwks.json 请求公钥。接口返回的是一串公钥,再根据kid定位到具体的某一个公钥,用于验签;
jwks=json-web-key-set,之所以要有多个公钥,是因为行业安全标准要求定期轮换密钥,防止私钥泄露造成隐患。轮换期间需要多个公钥并存。
claims
--- claims ---
{
"iss": "https://<projectId>.supabase.co/auth/v1",
"sub": "9b6dca09-1c45-4bfd-9fa4-586ac7035217",
"aud": "authenticated",
"exp": 1774710086,
"iat": 1774706486,
"email": "testname@example.com",
"phone": "",
"app_metadata": {
"provider": "email",
"providers": [
"email"
]
},
"user_metadata": {
"email": "testname@example.com",
"email_verified": true,
"phone_verified": false,
"sub": "9b6dca09-1c45-4bfd-9fa4-586ac7035217"
},
"role": "authenticated",
"aal": "aal1",
"amr": [
{
"method": "password",
"timestamp": 1774706486
}
],
"session_id": "a6c22824-4c3e-4531-b15f-b6f798f39029",
"is_anonymous": false
}
--- base64url ---
eyJpc3MiOiJodHRwczovL2JyZm1zcm51Y3Jld3R5eGVnb3poLnN1cGF......
- iss(Issuer):JWT的发布实体
- sub(subject):user id
- aud(audience): 令牌合法接受方,通常是给已认证(authenticated)角色使用
- exp(Expiration Time):过期时间
- iat(Issued At):token 签发时间
- role: 用户角色,authenticated,anon,service_role
- aal(authentication assurance level):认证保障级别。aal1 普通密码,aal2 已完成双因素认证
signature
--- signature base64 ---
BoOwVvx-z99mQXMuibNWkuf9eC9BkGHWEpqL3H6nTAdla2iI1J4ae7uLLa...
访问鉴权
getClaims 是访问鉴权的核心函数,它的工作流程是:
-
从 cookie 中取出并 decode access_token
-
检查有效期,如果即将过期,先利用 refresh_token 申请新的 access_token + refresh_token
-
准备验签,如果使用的非对称加密,supabase 先从 project-id.supabase.co/auth/v1/.we… 申请验签用的公钥
-
得到公钥后,在本地对 access_token 验签,过程类似:
// 很多开源库提供了对JWT的加密、解密
import { jwtVerify } from 'jose'
const const { payload } = await jwtVerify(session, decodKey, {algorithms: ['ES256']})
5. 如果验签通过,将解析后的 header, claims, signature 返回
如果使用对称加密,比如:HS256,则需要调用 supabase Auth Server 完成验签。supabase 推荐使用非对称加密,因为可以在本地完成,大大加快验签速度。
proxy
如果使用 nextjs,因为框架的渲染机制,我们无法在 Server Component 中写入 response cookie。supabase 推荐在 proxy 中完成鉴权和刷新。
export async function proxy(request: NextRequest): Promise<NextResponse> {
let response = NextResponse.next({
request
})
const supabase = createServerClient(process.env.SUPABASE_URL!, process.env.SUPABASE_PUBLISH_KEY!, {
cookies: {
getAll() {
// 从request读取cookie
return request.cookies.getAll()
},
setAll(cookiesToSet) {
// 如果token有刷新,将刷新后的token写入request,保证后续处理请求时拿到的是新token
cookiesToSet.forEach((cookie) => {
request.cookies.set(cookie.name, cookie.value)
})
response = NextResponse.next({
request
})
// 将刷新后的token写入response cookie
cookiesToSet.forEach((cookie) => {
response.cookies.set(cookie.name, cookie.value, cookie.options)
})
}
}
})
// 鉴权,刷新
const { data, error } = await supabase.auth.getClaims()
const claims = data?.claims
if (error) {
redirect("/auth-error")
}
}
export const config = {
// 正则表达式,定义使用proxy的route,把受鉴权保护的route放进来
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
令牌刷新
当access_token临近过期,supabase 会用 refresh_token 去兑换新的令牌。
注销
注销使用 signout,默认会结束所有用户相关的session。但也支持通过option控制注销的范围
const { error } = await supabase.auth.signOut({ scope: "global" })
scope 支持:
- global:删除用户所有session
- local:仅删除当前session,其他设备或浏览器的session仍然存在
- others:删除所有非当前session
supabas auth 重要数据表
users
用户信息表
create table auth.users (
instance_id uuid null,
id uuid not null,
aud character varying(255) null,
role character varying(255) null,
email character varying(255) null,
encrypted_password character varying(255) null,
email_confirmed_at timestamp with time zone null,
confirmation_sent_at timestamp with time zone null,
last_sign_in_at timestamp with time zone null,
raw_app_meta_data jsonb null,
raw_user_meta_data jsonb null,
created_at timestamp with time zone null,
updated_at timestamp with time zone null,
confirmed_at timestamp with time zone null,
deleted_at timestamp with time zone null,
);
sessions
登录设备清单表。记录谁登录了,用什么设备登录,令牌刷新情况。虽然叫session,但不是用来保存用户登录状态的。和那种有状态的登录管理,保存用户当前会话的方式不一样。supabase 使用无状态的 JWT 管理登录数据,鉴权需要的所有数据都在 token 中,通过 cookie 完整传递。
create table auth.sessions (
id uuid not null,
user_id uuid not null,
created_at timestamp with time zone null,
updated_at timestamp with time zone null,
aal auth.aal_level null,
not_after timestamp with time zone null, -- 强制过期时间
refreshed_at timestamp without time zone null, -- 最后一次刷新 Token 的时间
user_agent text null, -- 登录设备信息
ip inet null, -- 登录ip
tag text null,
refresh_token_hmac_key text null,
refresh_token_counter bigint null, -- 令牌刷新次数计数
);