Relyt One マネージド認証 入門
Relyt One 上でマルチユーザーの Next.js アプリを作るステップバイステップのガイドです。ブランチでマネージド認証を有効にし、ユーザーをサインインさせ、@relyt/auth で JWT を検証し、Postgres の行レベルセキュリティで各ユーザーの行を分離します。
目次
このガイドでは、Relyt One 上に小さなマルチユーザーのノート API を構築します。ユーザーはブランチのマネージド認証 URL でサインアップとサインインを行い、アプリは @relyt/auth でそのトークンを検証し、Postgres の行レベルセキュリティによって各ユーザーが自分のノートだけを読み書きできるようにします。その過程で、つまずきやすいポイントである信頼済みオリジン、発行者(issuer)の不一致、BYPASSRLS を持つロール、プールされた接続についても取り上げます。
必要なのは、Relyt One のプロジェクト、Node.js 20 以降、psql、そして 30 分ほどの時間です。ここで紹介する内容はすべて、オープンソースの TODO サンプルでも動作します。こちらは同じパターンに、ソーシャルサインイン、チームとロール、テストスイートといった機能を加えたものです。マネージド認証がなぜこのような仕組みになっているのか背景を知りたい方は、先に「Relyt One のマネージド認証、プレビュー版を公開」をお読みください。
- 行ごとに 1 人の所有者を持ち、行レベルセキュリティで保護された
notesテーブル。 - 呼び出し元のノートを一覧表示・作成する Next.js のルート
/api/notes。受け付けるのはAuthorization: Bearer <jwt>だけです。 - 認証 URL でユーザーをサインインさせ、新しい JWT を付けて API を呼び出すブラウザー用ヘルパー。
設計全体を支えているのは 2 つのルールで、以下のすべてのステップはここから導かれます。
- ユーザー ID は、検証済みの JWT からのみ取得する。 ヘッダー、クエリ文字列、リクエストボディからは決して取得しません。
- データベースでもチェックする。 API がユーザーで絞り込み、RLS がもう一度絞り込むので、
WHEREを書き忘れても他人の行が漏れることはありません。
コンソールでプロジェクトを開き、アプリで使うブランチを選びます。開発中は main の子ブランチがおすすめです。子ブランチの認証設定、セッション、署名キーはその子ブランチ独自のものなので、実験が本番のサインインに影響することはありません(main で認証を有効にした後に子ブランチを作成した場合、その子ブランチは main の信頼済みオリジンとエンドユーザーのコピーを持った状態で始まります。実際のユーザーデータを保持しているものとして扱ってください)。
Auth(認証)タブに移動し、Enable auth(認証を有効化)を選びます。初回のセットアップではブランチのコンピュートが起動され、認証用のテーブルが作成されます。1 分ほどかかるので、ページは開いたままにしておいてください。この操作ができるのは組織のオーナーと管理者だけです。
ステータスが Ready(準備完了)になると、概要に 2 つの URL が表示されます。
- Auth URL(認証 URL):ユーザーがサインインする場所であり、バックエンドがトークンの発行者として期待する値です。
- JWKS URL:バックエンドがトークンの署名をチェックするのに使う公開鍵です。
両方をコピーしておきましょう。これで、ユーザー、セッション、組織は、このブランチのデータベースの relyt_auth スキーマに保存されるようになります。
Sign-in methods(サインイン方法)で Email + password(メールアドレス + パスワード)が有効になっていること(デフォルトで有効です)を確認して保存します。このガイドで必要なのはこれだけです。
GitHub と Google も、OAuth アプリを用意すれば同じ手順で使えます。コンソールに表示されるコールバック URL(認証 URL を指すもので、たとえば <認証 URL>/api/auth/callback/github)をプロバイダー側の設定にコピーし、クライアント ID とシークレットをコンソールに貼り付けて Save & enable(保存して有効化)を選びます。シークレットは書き込み専用です。GitHub の OAuth アプリに登録できるコールバック URL は 1 つだけなので、ブランチごとに 1 つの OAuth アプリを使ってください。
認証 URL はアプリとは別のオリジンなので、アプリが誰かをサインインさせるたびに、ブラウザーは CORS リクエストを送信します。認証サービスが応答するのは、あなたが登録したオリジンだけです。
Redirects & origins(リダイレクトとオリジン)で、アプリの正確なオリジンを Trusted origins(信頼済みオリジン)に追加します。
- 開発中は
http://localhost:3000 - のちに
https://app.example.com(またはデプロイ先)
パスも、末尾のスラッシュも、ワイルドカードも付けません。あとでソーシャルサインインを追加する場合は、http://localhost:3000/auth/callback のようにサインインを完了するページも Callback URLs(コールバック URL)に登録してください。コールバック URL は完全一致で照合され、それぞれのオリジンも信頼済みオリジンである必要があります。
データベースのオーナー(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はテーブルのオーナーにも RLS を適用させますが、オーナーがBYPASSRLSを持っている場合は効きません。Relyt One のrelytdb_ownerはBYPASSRLSを持っているため、すべての行が見えます(次のポイント)。USINGは、クエリが参照・更新・削除できる行を絞り込みます。WITH CHECKは、他人が所有する行を作ることになる挿入や更新を拒否します。current_setting('app.user_id', true)は設定値を返し、一度も設定されていなければ何も返しません(trueは「エラーを発生させない」という意味です)。値がない場合はどの行もマッチしないので、設定し忘れたコードには、全員のノートではなく空のテーブルが見えます。
これは見落とされがちなステップで、飛ばすと上記のすべてが黙って無効になります。
スーパーユーザーと 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 のコマンドなので、パスワードがシェルの履歴やサーバーのログに残ることはありません。結果を確認します。
SELECT rolname, rolbypassrls, rolsuper FROM pg_roles WHERE rolname = 'notes_app';
-- rolname | rolbypassrls | rolsuper
-- -----------+--------------+----------
-- notes_app | f | fどちらの列も f でなければなりません。こうして作成したロールは、直接接続とプール経由のどちらの接続文字列でもログインできます。このステップの手順は、プーラー越しでもポリシーが効くことを含めて、実際のブランチで検証済みです。
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 の Auth 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発行者は認証 URL のオリジンと完全に一致していなければなりません。トークンは iss と aud の両方にその値を持っており、SDK はそれを 1 文字ずつ比較するからです。sslmode=require も重要です。Relyt One への接続には TLS が使われますが、node-postgres は明示的に指定しない限り TLS を有効にしません。
データベースへのアクセスはすべて 1 つの関数の背後にまとめます。この関数はトランザクションを開き、検証済みのユーザー 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 の第 3 引数は、見た目以上に重要です。これがないと設定はセッション全体にわたって持続し、コネクションプールを使っている場合、次にこの接続を借りたリクエストが前のユーザーとして実行されてしまいます。true を指定すれば、COMMIT または ROLLBACK の時点で設定は消えます。
PgBouncer のトランザクションプーリングの背後でも同じことが成り立ちます。設定がトランザクションより長く残ることはないからです。
@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 ヘッダーを付けることはないからです。
// 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 は、トークンから得たものです。
ブラウザーが通信する相手は 2 つです。サインインのための認証 URL と、JWT を付けて呼び出すあなたの API です。
// 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 に POST します。レスポンスのset-auth-tokenヘッダーにはセッショントークンが入っています。このトークンはユーザーのセッションそのものです。これを持つ人は誰でも、セッションが終了するまで、そのユーザーとして JWT を取得したり認証 URL のエンドポイントを呼び出したりできます。パスワードと同じように扱ってください。authClient.getToken()はセッショントークンを JWT(15 分、EdDSA)と交換してキャッシュし、有効期限が切れる約 2 分前に更新します。api()は JWT をあなたの API に送ります。ブラウザーがトークンを検証することはありません。それはサーバーの仕事です。
セッショントークンを localStorage に置いているのは、このガイドを短くするためです。ただし、そこに保存したものはページ上で動くどのスクリプトからも読み取れるため、クロスサイトスクリプティングのバグがあればトークンが漏れてしまいます。サインインを扱うページにはサードパーティのスクリプトを置かず、厳格な 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 を実行し、2 つのブラウザープロファイルで 2 人のユーザーをサインアップさせ、それぞれでノートを追加してみてください。どちらのユーザーにも自分のノートしか表示されません。
ブラウザーで確認できるのもよいですが、何かが欠けているときにアクセスが拒否されることまで確認できれば、なお安心です。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 を埋め込んだ両方のスニペットが用意されています。コールバックページのルール(ほかの何よりも先に completeSocialSignIn を実行すること、サインインは同じオリジンで開始して完了すること)は、TODO サンプルの README に記載されています。
ユーザーにアクティブな組織がある場合、その JWT には o クレームが含まれます。
"o": { "id": "…", "slug": "acme", "role": "member", "perms": ["todo:create", "todo:read"] }コンソールの Roles & permissions(ロールと権限)でリソースとアクションを定義し、各操作の前に API で o.perms をチェックします。共有する行については、org_id 列をキーにした 2 つ目の RLS ポリシーを追加します(withUser の中で、もう 1 つの set_config で値を設定します)。TODO サンプルはまさにこれを実装しており、権限の判断はすべて 1 つのモジュールにまとめられています。
| 症状 | 考えられる原因 |
|---|---|
| ブラウザーでのサインインがネットワークエラーで失敗する | アプリのオリジンが Trusted origins に登録されていない、またはパスや末尾のスラッシュが付いている |
すべての API 呼び出しが 500 を返し、サーバーログに @relyt/auth の config エラーが出ている | RELYT_AUTH_ISSUER がオリジンだけになっていない。末尾のスラッシュやパスが付いている、または localhost 以外のホストに http を使っている |
| 正しそうなトークンなのに、すべての API 呼び出しが 401 を返す | RELYT_AUTH_ISSUER が、トークンを発行したオリジンと異なる(たとえば別のブランチの認証 URL) |
| API が 503 を返す | サーバーから JWKS を取得できなかった。サーバーから JWKS URL への外向きのネットワークアクセスを確認する |
| データベース接続が失敗する、または応答しない | 接続文字列に ?sslmode=require が付いていない |
| クエリがフィルターを忘れたときに、ユーザー同士で互いの行が見える | アプリが BYPASSRLS を持つロールで接続している(コンソールで作成したユーザーと relytdb_owner は持っている)。ステップ 4 のロールを使う |
| クエリが 1 行も返さない | app.user_id がクエリと同じトランザクション内で設定されていない、またはクエリが withUser の外で実行された |
| あるリクエストに、ときどき別のユーザーの行が見える | プールされた接続で set_config を false(セッション全体)で呼び出している。true を使う |
- アプリは専用の
NOBYPASSRLSロール(relytdb_ownerではない)で接続しており、rolbypassrlsはfである。 - ユーザーデータを持つすべてのテーブルで、RLS が有効かつ強制されており、
USINGとWITH CHECKがある。 - ミドルウェアの背後にあっても、すべてのルートハンドラーが
requireUserを呼び出している。 - ユーザー ID は
claims.subからのみ取得している。 - サインアウト時に認証 URL の
/api/auth/sign-outを呼び出しており、セッショントークンを保持するページではサードパーティのスクリプトが動いていない。 - JWKS の障害時には 401 ではなく 503 を返す。
- 本番のオリジンが Trusted origins に登録されており、本番のコールバック URL が完全一致で登録されている。
- JWKS を自前でキャッシュする場合、キャッシュの保持期間を 960 秒未満にし、トークンに未知のキー ID が含まれていたら JWKS を再取得することで、キーのローテーションが機能する(
@relyt/authはすでに両方を行っている)。 - コンソールでユーザーのセッションを失効させても、発行済みの JWT は最大 15 分間有効なままであることを理解しており、それが問題になるケースをアプリで処理している。
- ソーシャルサインイン、チーム、ロール、そして上記すべてをチェックするテストスイートについては、TODO サンプルをクローンしてください。
createAuthClient、getClaims、createVerifierのすべてのオプションについては、@relyt/authの README をお読みください。- 設計とセキュリティモデルについては、「Relyt One のマネージド認証、プレビュー版を公開」をお読みください。



