# 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 サンプル](https://github.com/relytcloud/relyt-auth-examples/tree/main/todo)でも動作します。こちらは同じパターンに、ソーシャルサインイン、チームとロール、テストスイートといった機能を加えたものです。マネージド認証がなぜこのような仕組みになっているのか背景を知りたい方は、先に「[Relyt One のマネージド認証、プレビュー版を公開](/blog/ja/managed-auth-preview)」をお読みください。

> **注記**
>
> マネージド認証はプレビュー版です。手順は現時点のコンソールに合わせていますが、名前や制限は今後変わる可能性があります。

## 作るもの

![ブラウザーが認証 URL でサインインして JWT を受け取り、API が JWKS で検証して app.user_id を設定し、RLS が notes テーブルを絞り込む](/blog/assets/29483cec0b2a8d28.svg)

- 行ごとに 1 人の所有者を持ち、行レベルセキュリティで保護された `notes` テーブル。
- 呼び出し元のノートを一覧表示・作成する Next.js のルート `/api/notes`。受け付けるのは `Authorization: Bearer <jwt>` だけです。
- 認証 URL でユーザーをサインインさせ、新しい JWT を付けて API を呼び出すブラウザー用ヘルパー。

設計全体を支えているのは 2 つのルールで、以下のすべてのステップはここから導かれます。

1. **ユーザー ID は、検証済みの JWT からのみ取得する。** ヘッダー、クエリ文字列、リクエストボディからは決して取得しません。
2. **データベースでもチェックする。** API がユーザーで絞り込み、RLS がもう一度絞り込むので、`WHERE` を書き忘れても他人の行が漏れることはありません。

## ステップ 1：ブランチでマネージド認証を有効にする

コンソールでプロジェクトを開き、アプリで使うブランチを選びます。開発中は `main` の子ブランチがおすすめです。子ブランチの認証設定、セッション、署名キーはその子ブランチ独自のものなので、実験が本番のサインインに影響することはありません（`main` で認証を有効にした後に子ブランチを作成した場合、その子ブランチは `main` の信頼済みオリジンとエンドユーザーのコピーを持った状態で始まります。実際のユーザーデータを保持しているものとして扱ってください）。

**Auth**（認証）タブに移動し、**Enable auth**（認証を有効化）を選びます。初回のセットアップではブランチのコンピュートが起動され、認証用のテーブルが作成されます。1 分ほどかかるので、ページは開いたままにしておいてください。この操作ができるのは組織のオーナーと管理者だけです。

ステータスが **Ready**（準備完了）になると、概要に 2 つの URL が表示されます。

- **Auth URL**（認証 URL）：ユーザーがサインインする場所であり、バックエンドがトークンの発行者として期待する値です。
- **JWKS URL**：バックエンドがトークンの署名をチェックするのに使う公開鍵です。

両方をコピーしておきましょう。これで、ユーザー、セッション、組織は、このブランチのデータベースの `relyt_auth` スキーマに保存されるようになります。

## ステップ 2：サインイン方法を有効にする

**Sign-in methods**（サインイン方法）で **Email + password**（メールアドレス + パスワード）が有効になっていること（デフォルトで有効です）を確認して保存します。このガイドで必要なのはこれだけです。

GitHub と Google も、OAuth アプリを用意すれば同じ手順で使えます。コンソールに表示されるコールバック URL（認証 URL を指すもので、たとえば `<認証 URL>/api/auth/callback/github`）をプロバイダー側の設定にコピーし、クライアント ID とシークレットをコンソールに貼り付けて **Save & enable**（保存して有効化）を選びます。シークレットは書き込み専用です。GitHub の OAuth アプリに登録できるコールバック URL は 1 つだけなので、ブランチごとに 1 つの OAuth アプリを使ってください。

## ステップ 3：アプリのオリジンを信頼する

認証 URL はアプリとは別のオリジンなので、アプリが誰かをサインインさせるたびに、ブラウザーは CORS リクエストを送信します。認証サービスが応答するのは、あなたが登録したオリジンだけです。

**Redirects & origins**（リダイレクトとオリジン）で、アプリの正確なオリジンを **Trusted origins**（信頼済みオリジン）に追加します。

- 開発中は `http://localhost:3000`
- のちに `https://app.example.com`（またはデプロイ先）

パスも、末尾のスラッシュも、ワイルドカードも付けません。あとでソーシャルサインインを追加する場合は、`http://localhost:3000/auth/callback` のようにサインインを完了するページも **Callback URLs**（コールバック URL）に登録してください。コールバック URL は完全一致で照合され、それぞれのオリジンも信頼済みオリジンである必要があります。

> **ヒント**
>
> ブラウザーでのサインインが一般的なネットワークエラーで失敗する場合、原因はほぼ確実にこのステップです。

## ステップ 4：テーブル、ポリシー、アプリケーションロールを作成する

データベースのオーナー（`relytdb` データベースの `relytdb_owner`。パスワードはプロジェクト作成時に設定したもの）としてブランチに接続し、テーブルを作成します。

```sql
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 です。

続いて行レベルセキュリティを有効にし、各行の所有者と、アプリがトランザクションごとに設定する値とを比較するポリシーを追加します。

```sql
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` は「エラーを発生させない」という意味です）。値がない場合はどの行もマッチしないので、設定し忘れたコードには、全員のノートではなく空のテーブルが見えます。

### RLS が適用されるロールで接続する

これは見落とされがちなステップで、飛ばすと上記のすべてが黙って無効になります。

スーパーユーザーと `BYPASSRLS` を持つロールは、すべてのポリシーを無視します。**Relyt One コンソールで作成したデータベースユーザーは `BYPASSRLS` を持っています。すべてのプロジェクトに最初からあるオーナーロール `relytdb_owner` も同じです。** オーナーはテーブルの作成にだけ使い、リクエストの処理には使わないでください。アプリがそのいずれかで接続すると、ポリシーは一切実行されず、ユーザー同士を隔てるものはアプリケーションコードだけになります。

代わりに、SQL でアプリ専用のロールを作成してください。

```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 のコマンドなので、パスワードがシェルの履歴やサーバーのログに残ることはありません。結果を確認します。

```sql
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 にはまだ対応していないため、バージョンを固定してください）。

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

`.env.local` に設定を追加します。

```bash
# ステップ 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 を有効にしません。

## ステップ 6：データベースへの入り口を 1 つにする

データベースへのアクセスはすべて 1 つの関数の背後にまとめます。この関数はトランザクションを開き、検証済みのユーザー ID から `app.user_id` を設定してから、ようやくクエリを実行します。

```ts
// 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 のトランザクションプーリングの背後でも同じことが成り立ちます。設定がトランザクションより長く残ることはないからです。

## ステップ 7：リクエストごとにトークンを検証する

`@relyt/auth/next` の `getClaims` は、Bearer トークンを読み取り、JWKS に対して EdDSA 署名をチェックし、発行者、オーディエンス、有効期限をチェックしたうえで、クレームを返します。トークンがない、または無効な場合は `null` を返します。

```ts
// 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

```ts
// 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：ブラウザーからサインインする

ブラウザーが通信する相手は 2 つです。サインインのための認証 URL と、JWT を付けて呼び出すあなたの API です。

```ts
// 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 にも格納する、より堅牢な構成を紹介しています。

試すための最小限のページです。

```tsx
// 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 人のユーザーをサインアップさせ、それぞれでノートを追加してみてください。どちらのユーザーにも自分のノートしか表示されません。

## ステップ 10：ターミナルで分離を確かめる

ブラウザーで確認できるのもよいですが、何かが欠けているときにアクセスが拒否されることまで確認できれば、なお安心です。`curl` でユーザーの JWT を取得します。

```bash
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"
```

次に、失敗するはずの操作を試します。

```bash
# トークンなし：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` を設定しない状態では、ノートがいくつあってもテーブルは空です。

```sql
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` クレームが含まれます。

```json
"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 サンプル](https://github.com/relytcloud/relyt-auth-examples/tree/main/todo)をクローンしてください。
- `createAuthClient`、`getClaims`、`createVerifier` のすべてのオプションについては、[`@relyt/auth` の README](https://github.com/relytcloud/relyt-auth-js#readme) をお読みください。
- 設計とセキュリティモデルについては、「[Relyt One のマネージド認証、プレビュー版を公開](/blog/ja/managed-auth-preview)」をお読みください。

[無料でプロジェクトを作成](/auth/signin?ref=blog-managed-auth-getting-started)
