学习笔记 - supabase ssr 登录管理

3 阅读9分钟

创建客户端

这是所有操作的前提,在服务端管理登录态,我们需要专门的 @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 是访问鉴权的核心函数,它的工作流程是:

  1. 从 cookie 中取出并 decode access_token

  2. 检查有效期,如果即将过期,先利用 refresh_token 申请新的 access_token + refresh_token

  3. 准备验签,如果使用的非对称加密,supabase 先从 project-id.supabase.co/auth/v1/.we… 申请验签用的公钥

  4. 得到公钥后,在本地对 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, -- 令牌刷新次数计数
);