Relyt One 托管认证入门

一步步在 Relyt One 上搭建一个多用户的 Next.js 应用:为分支启用托管认证、让用户登录、用 @relyt/auth 验证他们的 JWT,并由 Postgres 行级安全把每个用户的行隔离开。

Zhan Lu更新于 2026年10月30日阅读约 12 分钟
本页目录

在本指南中,我们会在 Relyt One 上构建一个小型的多用户笔记 API。用户在分支的托管认证 URL 上注册和登录,应用用 @relyt/auth 验证他们的令牌,Postgres 行级安全则确保每个用户只能读写自己的笔记。过程中我们还会讲到最容易出错的几处:可信来源、issuer 不匹配、BYPASSRLS 角色和连接池中的连接。

你需要一个 Relyt One 项目、Node.js 20 或更高版本、psql,以及大约三十分钟。这里的所有内容也适用于开源的 TODO 示例,它采用同样的模式,功能更多(社交登录、团队与角色,以及测试套件)。如果你想先了解托管认证为什么这样设计,请先阅读 Relyt One 托管认证预览版发布。

我们要构建什么

浏览器在认证 URL 上登录并获得 JWT,API 用 JWKS 验证它并设置 app.user_id,RLS 过滤 notes 表

  • 一张 notes 表,每行有一个所有者,受行级安全保护。
  • 一个 Next.js 路由 /api/notes,列出并创建调用方的笔记。它只接受 Authorization: Bearer <jwt>。
  • 一个浏览器端辅助模块,在认证 URL 上为用户登录,并带着新鲜的 JWT 调用 API。

整个设计靠两条规则撑起来,下面的每一步都由它们推导而来:

  1. 用户 ID 只能来自经过验证的 JWT。 绝不能来自请求头、查询字符串或请求体。
  2. 数据库也要检查。 API 按用户过滤一次,RLS 再过滤一次,所以即使忘了写 WHERE,也不会泄露别人的行。

第 1 步:为分支启用托管认证

在控制台中打开你的项目,选择应用要使用的分支。开发阶段,main 的一个子分支是不错的选择:它的认证设置、会话和签名密钥都是独立的,所以实验永远不会影响生产环境的登录。(如果你是在 main 启用认证之后才创建这个子分支的,它一开始会带有 main 的可信来源和终端用户的副本,因此要把它当作存有真实用户数据的分支来对待。)

进入 Auth(认证)标签页,选择 Enable auth(启用认证)。首次设置会唤醒分支的计算实例并创建认证相关的表,大约需要一分钟,请保持页面打开。只有组织的所有者和管理员才能执行此操作。

状态变为 Ready(就绪)后,概览中会显示两个 URL:

  • Auth URL(认证 URL):你的用户在这里登录,也是你的后端期望的令牌签发方(issuer)的值。
  • JWKS URL:你的后端用来检查令牌签名的公钥。

把两个都复制下来。现在,用户、会话和组织都存储在这个分支数据库的 relyt_auth schema 中。

第 2 步:开启一种登录方式

在 Sign-in methods(登录方式)下,确认 Email + password(邮箱 + 密码)已启用(默认开启)并保存。本指南只需要这一种。

有了 OAuth App 之后,GitHub 和 Google 的配置方式也一样:把控制台显示的回调 URL(它指向认证 URL,例如 <认证 URL>/api/auth/callback/github)复制到服务商的设置中,再把 Client ID 和 Client Secret 粘贴到控制台,选择 Save & enable(保存并启用)。Secret 只能写入。一个 GitHub OAuth App 只能注册一个回调 URL,所以请每个分支使用一个 OAuth App。

第 3 步:信任你的应用来源

认证 URL 和你的应用是不同的源,所以你的应用每次让用户登录时,浏览器都会发出一个 CORS 请求。认证服务只响应你列出的来源。

在 Redirects & origins(重定向与来源)下,把应用的精确来源添加到 Trusted origins(可信来源):

  • 开发时添加 http://localhost:3000;
  • 之后添加 https://app.example.com(或你部署的任何地址)。

不带路径、不带末尾斜杠、不用通配符。如果之后要加社交登录,还要在 Callback URLs(回调 URL)下注册完成登录的那个页面,例如 http://localhost:3000/auth/callback。回调 URL 是精确匹配的,而且每个回调 URL 的来源也必须是可信来源。

第 4 步:创建表、策略和应用角色

以数据库所有者身份(即 relytdb 数据库上的 relytdb_owner,密码是你创建项目时设置的那个)连接到分支,创建表:

CREATE TABLE notes (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id    text NOT NULL,
  body       text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX notes_user_id_idx ON notes (user_id, created_at DESC);

user_id 存放用户 JWT 中的 sub 声明,它是一个文本 ID。

现在开启行级安全,并添加一条策略,把每一行的所有者与应用在每个事务中设置的一个配置项进行比较:

ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE notes FORCE ROW LEVEL SECURITY;

CREATE POLICY notes_owner ON notes FOR ALL
  USING (user_id = current_setting('app.user_id', true))
  WITH CHECK (user_id = current_setting('app.user_id', true));

各部分的作用:

  • ENABLE 开启 RLS;FORCE 让它对表的所有者同样生效,除非所有者带有 BYPASSRLS。在 Relyt One 上,relytdb_owner 就带有 BYPASSRLS,所以它仍能看到所有行(见下一节)。
  • USING 过滤一条查询能看到、更新或删除哪些行。
  • WITH CHECK 拒绝会产生「属于别人的行」的插入和更新。
  • current_setting('app.user_id', true) 返回该配置项,如果从未设置过则什么也不返回(true 表示「不要报错」)。配置项缺失时没有任何行能匹配,所以忘了设置它的代码看到的是一张空表,而不是所有人的笔记。

以受 RLS 约束的角色连接

这是大家最常跳过的一步,而它会悄无声息地让上面的一切失效。

超级用户和带有 BYPASSRLS 的角色会无视所有策略。通过 Relyt One 控制台创建的数据库用户带有 BYPASSRLS,每个项目自带的所有者角色 relytdb_owner 也带有。 所有者只用来建表,不要拿它来处理请求。如果你的应用以这样的用户连接,策略根本不会执行,唯一把用户隔开的就只剩你的应用代码了。

请改用 SQL 为应用创建一个专用角色:

CREATE ROLE notes_app LOGIN NOBYPASSRLS NOSUPERUSER NOCREATEROLE NOCREATEDB;
\password notes_app
GRANT USAGE ON SCHEMA public TO notes_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO notes_app;

\password 是一个 psql 命令,会提示你输入密码,这样密码就不会留在 shell 历史或服务器日志里。检查结果:

SELECT rolname, rolbypassrls, rolsuper FROM pg_roles WHERE rolname = 'notes_app';
--  rolname   | rolbypassrls | rolsuper
-- -----------+--------------+----------
--  notes_app | f            | f

两列都必须是 f。这样建出来的角色经直连和连接池两种连接字符串都能登录;本步的整套做法我们已在真实分支上验证过,包括策略在连接池后面同样生效。

第 5 步:搭建应用

创建一个 Next.js 15 应用,安装 SDK 和 Postgres 驱动(@relyt/auth/next 支持 Next.js 14.2.35+ 和 15.5.28+,暂不支持 16,所以请固定版本):

npx create-next-app@15 notes --ts --app
cd notes
npm install @relyt/auth pg
npm install -D @types/pg

把配置写入 .env.local:

# 第 1 步中的认证 URL:只写源,不带末尾斜杠,不带路径。
RELYT_AUTH_ISSUER=https://<你的认证 URL>
RELYT_AUTH_JWKS_URL=https://<你的 JWKS URL>

# 同一个认证 URL,供浏览器使用。
NEXT_PUBLIC_RELYT_AUTH_URL=https://<你的认证 URL>

# 以第 4 步创建的角色 notes_app 连接,绝不要用控制台创建的用户。
DATABASE_URL=postgresql://notes_app:<密码>@<主机>/relytdb?sslmode=require

issuer 必须与认证 URL 的源完全一致,因为令牌的 iss 和 aud 中都带着这个值,而 SDK 会逐字符比较。sslmode=require 同样重要:连接 Relyt One 需要使用 TLS,而 node-postgres 默认不会开启它,除非你显式要求。

第 6 步:通往数据库的唯一入口

把所有数据库访问都收拢到一个函数后面:它开启一个事务,用经过验证的用户 ID 设置 app.user_id,然后才执行你的查询。

// lib/db.ts
import { Pool, type PoolClient } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });

export async function withUser<T>(userId: string, fn: (db: PoolClient) => Promise<T>): Promise<T> {
  if (!userId) throw new Error("withUser: empty user id");
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    // `true` = 仅在当前事务内有效:连接池中的连接永远不会把它带到下一个请求。
    await client.query("SELECT set_config('app.user_id', $1, true)", [userId]);
    const result = await fn(client);
    await client.query("COMMIT");
    client.release();
    return result;
  } catch (e) {
    await client.query("ROLLBACK").catch(() => undefined);
    client.release(true); // 丢弃可能已损坏的连接,而不是把它放回连接池
    throw e;
  }
}

set_config 的第三个参数比看上去重要得多。没有它,这个配置项会在整个会话期间一直有效;在连接池下,下一个借用这条连接的请求就会以上一个用户的身份运行。传入 true 后,它会在 COMMIT 或 ROLLBACK 时消失。

在 PgBouncer 的事务级连接池后面也是如此,因为这个配置项的生命周期永远不会超过事务。

第 7 步:在每个请求上验证令牌

@relyt/auth/next 中的 getClaims 会读取 Bearer 令牌,对照 JWKS 检查它的 EdDSA 签名,检查签发方、受众和过期时间,然后返回声明;如果令牌缺失或无效,则返回 null。

// lib/auth.ts
import { getClaims, RelytAuthUnavailableError } from "@relyt/auth/next";

export type Caller = { ok: true; userId: string } | { ok: false; response: Response };

export async function requireUser(req: Request): Promise<Caller> {
  try {
    const claims = await getClaims(
      req,
      { issuer: process.env.RELYT_AUTH_ISSUER!, jwksUrl: process.env.RELYT_AUTH_JWKS_URL },
      { cookie: false }, // API 路由只接受 Bearer 令牌
    );
    if (!claims?.sub) {
      return { ok: false, response: Response.json({ error: "unauthorized" }, { status: 401, headers: { "www-authenticate": "Bearer" } }) };
    }
    return { ok: true, userId: claims.sub };
  } catch (e) {
    if (e instanceof RelytAuthUnavailableError) {
      // 无法获取公钥。不要返回 401:那样会在一次网络抖动中让所有人都退出登录。
      return { ok: false, response: Response.json({ error: "auth_unavailable" }, { status: 503, headers: { "retry-after": "2" } }) };
    }
    throw e;
  }
}

只接受 Bearer 令牌(cookie: false)能让 API 免受 CSRF 攻击:浏览器绝不会自行在跨站请求上附带 Authorization 请求头。

第 8 步:笔记 API

// app/api/notes/route.ts
import { requireUser } from "@/lib/auth";
import { withUser } from "@/lib/db";

export async function GET(req: Request) {
  const caller = await requireUser(req);
  if (!caller.ok) return caller.response;

  const notes = await withUser(caller.userId, async (db) => {
    const { rows } = await db.query(
      "SELECT id, body, created_at FROM notes WHERE user_id = $1 ORDER BY created_at DESC LIMIT 100",
      [caller.userId],
    );
    return rows;
  });
  return Response.json(notes);
}

export async function POST(req: Request) {
  const caller = await requireUser(req);
  if (!caller.ok) return caller.response;

  const input = await req.json().catch(() => null);
  const body = typeof input?.body === "string" ? input.body.trim() : "";
  if (body.length < 1 || body.length > 2000) return Response.json({ error: "invalid_body" }, { status: 400 });

  const note = await withUser(caller.userId, async (db) => {
    const { rows } = await db.query(
      "INSERT INTO notes (user_id, body) VALUES ($1, $2) RETURNING id, body, created_at",
      [caller.userId, body],
    );
    return rows[0];
  });
  return Response.json(note, { status: 201 });
}

注意,即使 RLS 本来就会过滤,查询里仍然写着 WHERE user_id = $1。两者都保留是有意为之:查询快速而明确,策略则是安全网,防的是哪天有人写出一条没带过滤条件的查询。

还要注意这个处理器没有做什么:它从不从请求体或查询字符串中读取用户 ID。它写入的 user_id 就是令牌里的那个。

第 9 步:在浏览器中登录

浏览器要和两个地方打交道:认证 URL,用于登录;你的 API,带着 JWT 调用。

// lib/auth-client.ts
"use client";
import { createAuthClient } from "@relyt/auth";

const authUrl = process.env.NEXT_PUBLIC_RELYT_AUTH_URL!;
const KEY = "notes.session-token";

export const authClient = createAuthClient({
  authUrl,
  sessionToken: () => localStorage.getItem(KEY),
  onSessionToken: (t) => localStorage.setItem(KEY, t),
});

export async function emailAuth(mode: "sign-in" | "sign-up", email: string, password: string) {
  const body = mode === "sign-up" ? { email, password, name: email.split("@")[0] } : { email, password };
  const res = await fetch(`${authUrl}/api/auth/${mode}/email`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
    credentials: "omit",
  });
  if (!res.ok) throw new Error(`${mode} failed (HTTP ${res.status})`);
  const token = res.headers.get("set-auth-token");
  if (!token) throw new Error("No session token returned. Is this origin a trusted origin?");
  localStorage.setItem(KEY, token);
}

export async function api(path: string, init: RequestInit = {}) {
  const jwt = await authClient.getToken(); // 已缓存;过期前自动刷新
  const headers = new Headers(init.headers);
  headers.set("authorization", `Bearer ${jwt}`);
  return fetch(path, { ...init, headers });
}

export async function signOut() {
  const token = localStorage.getItem(KEY);
  if (token) {
    // 在认证 URL 上结束会话,而不只是在当前标签页中清除
    await fetch(`${authUrl}/api/auth/sign-out`, { method: "POST", headers: { authorization: `Bearer ${token}` }, credentials: "omit" }).catch(() => undefined);
  }
  localStorage.removeItem(KEY);
  authClient.clear();
}

各部分如何配合:

  • emailAuth 向认证 URL 发送请求。响应的 set-auth-token 响应头中带有一个会话令牌。这个令牌就是用户的会话:在会话结束之前,任何持有它的人都能以该用户的身份换取 JWT、调用认证 URL 的各个接口,所以要像对待密码一样对待它。
  • authClient.getToken() 用会话令牌换取一个 JWT(15 分钟,EdDSA)并缓存起来,在过期前大约两分钟刷新。
  • api() 把 JWT 发给你的 API。浏览器从不验证令牌,那是服务端的事。

把会话令牌放在 localStorage 里,是为了让本指南保持简短;但存放在那里的任何内容,都能被页面上运行的任意脚本读取,所以一个跨站脚本(XSS)漏洞就会让它暴露。请不要在处理登录的页面上加载第三方脚本,并使用严格的内容安全策略(Content Security Policy)。TODO 示例展示了一种更稳健的做法:还会把 JWT 放进 HttpOnly Cookie,供页面导航使用。

一个用来试用的最小页面:

// app/page.tsx
"use client";
import { useState } from "react";
import { api, emailAuth } from "@/lib/auth-client";

export default function Home() {
  const [notes, setNotes] = useState<{ id: string; body: string }[]>([]);
  const load = async () => setNotes(await (await api("/api/notes")).json());

  return (
    <main>
      <form
        onSubmit={async (e) => {
          e.preventDefault();
          const f = new FormData(e.currentTarget);
          await emailAuth("sign-up", String(f.get("email")), String(f.get("password")));
          await load();
        }}
      >
        <input name="email" type="email" required />
        <input name="password" type="password" required />
        <button>Sign up</button>
      </form>
      <button onClick={async () => { await api("/api/notes", { method: "POST", body: JSON.stringify({ body: "hello" }) }); await load(); }}>
        Add note
      </button>
      <ul>{notes.map((n) => <li key={n.id}>{n.body}</li>)}</ul>
    </main>
  );
}

运行 npm run dev,在两个浏览器配置文件中分别注册两个用户,各自添加一条笔记。每个用户都只能看到自己的笔记。

第 10 步:在终端里证明隔离生效

在浏览器里看到效果固然不错,看到它在缺了东西时拒绝访问,才更让人放心。用 curl 为一个用户获取 JWT:

AUTH=https://<你的认证 URL>
ORIGIN=http://localhost:3000

SESSION=$(curl -s -D - -o /dev/null "$AUTH/api/auth/sign-in/email" \
  -H "origin: $ORIGIN" -H "content-type: application/json" \
  -d '{"email":"alice@example.com","password":"<password>"}' \
  | awk 'tolower($1)=="set-auth-token:" {print $2}' | tr -d '\r')

JWT=$(curl -s "$AUTH/api/auth/token" -H "authorization: Bearer $SESSION" | sed 's/.*"token":"\([^"]*\)".*/\1/')

curl -s http://localhost:3000/api/notes -H "authorization: Bearer $JWT"

然后试试那些应该失败的情况:

# 没有令牌:401
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/notes

# 被篡改的令牌:401
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/notes -H "authorization: Bearer ${JWT}x"

再直接检查数据库那一半的承诺。以 notes_app 身份连接、不设置 app.user_id 时,无论实际有多少条笔记,这张表都是空的:

SELECT count(*) FROM notes;            -- 0
BEGIN;
SELECT set_config('app.user_id', '<alice 的 sub>', true);
SELECT count(*) FROM notes;            -- 只有 Alice 的笔记
COMMIT;

如果第一条查询返回了所有笔记,说明你的应用是以一个绕过 RLS 的角色连接的。请回到第 4 步。

更进一步

社交登录

startSocialSignIn 通过认证 URL 把用户送到 GitHub 或 Google,completeSocialSignIn 则在你的回调页面上用一次性交接码和 PKCE 完成登录,因此无需第三方 Cookie 也能工作。控制台的 Integration code(集成代码)面板提供了这两段代码,并已填好你的分支 URL。TODO 示例的 README 列出了回调页面需要遵守的规则(它必须在做任何其他事情之前先运行 completeSocialSignIn,并且登录必须在同一个源上开始和结束)。

团队、角色与权限

当用户有一个活跃组织时,他们的 JWT 会带有一个 o 声明:

"o": { "id": "…", "slug": "acme", "role": "member", "perms": ["todo:create", "todo:read"] }

在控制台的 Roles & permissions(角色与权限)下定义资源和操作,在 API 中执行每个操作前检查 o.perms,并为共享的行添加第二条基于 org_id 列的 RLS 策略(在 withUser 中再调用一次 set_config 来设置它)。TODO 示例正是这样实现的,所有权限判断都集中在一个模块里。

故障排查

现象可能的原因
浏览器中登录失败,报网络错误应用的来源不在 Trusted origins 中,或者带了路径或末尾斜杠
每次 API 调用都返回 500,服务端日志中有来自 @relyt/auth 的 config 错误RELYT_AUTH_ISSUER 不是一个单纯的源:带了末尾斜杠或路径,或者对 localhost 以外的主机使用了 http
令牌看起来有效,但每次 API 调用都返回 401RELYT_AUTH_ISSUER 与签发该令牌的源不同,例如用了另一个分支的认证 URL
API 返回 503服务端无法获取 JWKS。请检查服务端到 JWKS URL 的出站网络访问
数据库连接失败或卡住连接字符串缺少 ?sslmode=require
查询漏写过滤条件时,用户能看到彼此的行应用以带有 BYPASSRLS 的角色连接(控制台创建的用户和 relytdb_owner 都带有它)。请使用第 4 步创建的角色
查询完全不返回任何行app.user_id 没有在与查询相同的事务中设置,或者查询运行在 withUser 之外
某个请求间歇性地看到另一个用户的行在连接池的连接上以 false(整个会话有效)调用了 set_config。请使用 true

上线之前

  • 应用以专用的 NOBYPASSRLS 角色连接(不是 relytdb_owner),且 rolbypassrls 为 f。
  • 每张存有用户数据的表都已启用并强制启用 RLS,同时带有 USING 和 WITH CHECK。
  • 每个路由处理器都调用了 requireUser,即使前面已经有中间件。
  • 用户 ID 只来自 claims.sub。
  • 退出登录时会调用认证 URL 的 /api/auth/sign-out,并且持有会话令牌的页面上不运行任何第三方脚本。
  • JWKS 不可用时返回 503,而不是 401。
  • 生产环境的来源已加入 Trusted origins,生产环境的回调 URL 已精确注册。
  • 如果你自己缓存 JWKS,缓存时间不超过 960 秒,并且在令牌带有未知密钥 ID 时重新获取,这样密钥轮换才能正常工作。(@relyt/auth 已经同时做到了这两点。)
  • 你清楚在控制台中撤销某个用户的会话后,已签发的 JWT 最多仍会有效 15 分钟,并且你的应用处理好了这一点会造成影响的场景。

下一步

创建免费项目

标签认证Postgres安全nextjs

返回博客