# Relyt One 관리형 인증 시작하기

Relyt One에서 다중 사용자 Next.js 앱을 만드는 단계별 가이드입니다. 브랜치에서 관리형 인증을 활성화하고, 사용자를 로그인시키고, @relyt/auth로 JWT를 검증하고, Postgres 행 수준 보안으로 사용자마다 행을 분리합니다.

**이 가이드에서는 Relyt One에서 작은 다중 사용자 노트 API를 만듭니다. 사용자는 브랜치의 관리형 인증 URL에서 가입하고 로그인하며, 앱은 `@relyt/auth`로 사용자의 토큰을 검증하고, Postgres 행 수준 보안은 각 사용자가 자신의 노트만 읽고 쓰도록 보장합니다. 그 과정에서 흔히 문제가 되는 부분인 신뢰할 수 있는 출처, 발급자 불일치, `BYPASSRLS` 역할, 풀링된 연결도 다룹니다.**

Relyt One 프로젝트, Node.js 20 이상, `psql`, 그리고 약 30분이 필요합니다. 여기서 다루는 모든 내용은 오픈 소스 [TODO 예제](https://github.com/relytcloud/relyt-auth-examples/tree/main/todo)에서도 그대로 작동합니다. 이 예제는 같은 패턴에 더 많은 기능(소셜 로그인, 팀과 역할, 테스트 모음)을 더한 것입니다. 관리형 인증이 왜 이런 방식으로 작동하는지 배경을 알고 싶다면 먼저 [Relyt One 관리형 인증, 미리 보기 공개](/blog/ko/managed-auth-preview)를 읽어 보세요.

> **참고**
>
> 관리형 인증은 미리 보기 단계입니다. 이 단계들은 현재 콘솔과 일치하지만, 이름과 한도는 바뀔 수 있습니다.

## 만들 것

![브라우저는 인증 URL에서 로그인해 JWT를 받고, API는 JWKS로 JWT를 검증해 app.user_id를 설정하며, RLS가 notes 테이블을 필터링합니다](/blog/assets/3d0199b08b840540.svg)

- 행마다 소유자가 하나인, 행 수준 보안으로 보호되는 `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**(인증 활성화)를 선택하세요. 처음 설정할 때는 브랜치의 컴퓨트를 깨우고 인증 테이블을 만듭니다. 약 1분이 걸리니 페이지를 열어 두세요. 이 작업은 조직 소유자와 관리자만 할 수 있습니다.

상태가 **Ready**(준비됨)로 바뀌면 개요에 두 개의 URL이 표시됩니다.

- **Auth URL**(인증 URL): 사용자가 로그인하는 곳이며, 백엔드가 토큰 발급자로 기대하는 값입니다.
- **JWKS URL**: 백엔드가 토큰 서명을 확인하는 데 사용하는 공개 키입니다.

둘 다 복사해 두세요. 이제 사용자, 세션, 조직은 이 브랜치 데이터베이스의 `relyt_auth` 스키마에 저장됩니다.

## 2단계: 로그인 방법 켜기

**Sign-in methods**(로그인 방법)에서 **Email + password**(이메일 + 비밀번호)가 활성화되어 있는지 확인하고(기본값으로 켜져 있습니다) 저장하세요. 이 가이드에는 그것만 있으면 됩니다.

GitHub와 Google도 OAuth 앱만 있으면 같은 방식으로 작동합니다. 콘솔에 표시되는 콜백 URL(인증 URL을 가리키며, 예를 들면 `<인증 URL>/api/auth/callback/github`)을 제공자 설정에 복사한 다음, Client ID와 Secret을 콘솔에 붙여 넣고 **Save & enable**(저장 및 활성화)을 선택하세요. Secret은 쓰기 전용입니다. GitHub OAuth 앱에는 콜백 URL을 하나만 등록할 수 있으므로 브랜치마다 OAuth 앱을 하나씩 사용하세요.

## 3단계: 앱의 출처 신뢰하기

인증 URL은 앱과 다른 출처이므로, 앱이 누군가를 로그인시킬 때마다 브라우저는 CORS 요청을 보냅니다. 인증 서비스는 여러분이 등록한 출처에만 응답합니다.

**Redirects & origins**(리디렉션 및 출처)에서 앱의 정확한 출처를 **Trusted origins**(신뢰할 수 있는 출처)에 추가하세요.

- 개발 중에는 `http://localhost:3000`
- 나중에는 `https://app.example.com`(또는 배포하는 곳)

경로, 끝의 슬래시, 와일드카드는 쓰지 않습니다. 나중에 소셜 로그인을 추가한다면 로그인을 마무리하는 페이지(예: `http://localhost:3000/auth/callback`)도 **Callback URLs**(콜백 URL)에 등록하세요. 콜백 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`는 소유자에게 `BYPASSRLS`가 없는 한 테이블 소유자에게도 RLS가 적용되게 합니다. 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단계의 인증 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는 이를 한 글자씩 비교하기 때문입니다. `sslmode=require`도 중요합니다. Relyt One에 대한 연결은 TLS를 사용하지만, node-postgres는 따로 요청하지 않으면 TLS를 켜지 않습니다.

## 6단계: 데이터베이스로 들어가는 단 하나의 길

모든 데이터베이스 접근을 하나의 함수 뒤에 두세요. 이 함수는 트랜잭션을 열고, 검증된 사용자 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`의 세 번째 인수는 보기보다 중요합니다. 이 인수가 없으면 설정이 세션 전체에 유지되므로, 연결 풀을 쓰면 이 연결을 빌린 다음 요청이 이전 사용자로 실행됩니다. `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단계: 브라우저에서 로그인하기

브라우저는 두 곳과 통신합니다. 로그인을 위한 인증 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로 요청을 보냅니다. 응답의 `set-auth-token` 헤더에 **세션 토큰**이 담겨 옵니다. 이 토큰이 곧 사용자의 세션입니다. 토큰을 가진 사람은 세션이 끝날 때까지 그 사용자로서 JWT를 받고 인증 URL의 엔드포인트를 호출할 수 있으므로, 비밀번호처럼 다루세요.
- `authClient.getToken()`은 세션 토큰을 **JWT**(15분, EdDSA)로 교환해 캐시하고, 만료되기 약 2분 전에 갱신합니다.
- `api()`는 JWT를 API로 보냅니다. 브라우저는 토큰을 검증하지 않습니다. 검증은 서버의 일입니다.

세션 토큰을 `localStorage`에 두면 이 가이드가 짧아지지만, 그곳에 저장된 것은 페이지에서 실행되는 모든 스크립트가 읽을 수 있으므로 크로스 사이트 스크립팅 버그가 있으면 노출됩니다. 로그인을 처리하는 페이지에는 서드 파티 스크립트를 두지 말고 엄격한 콘텐츠 보안 정책(CSP)을 사용하세요. TODO 예제는 페이지 탐색용으로 JWT를 `HttpOnly` 쿠키에도 넣는, 더 견고한 구성을 보여 줍니다.

시험해 볼 최소한의 페이지는 다음과 같습니다.

```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`를 실행하고, 두 개의 브라우저 프로필에서 사용자 두 명을 가입시킨 뒤 각각 노트를 추가하세요. 각 사용자는 자신의 노트만 봅니다.

## 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를 사용해 콜백 페이지에서 로그인을 마무리하므로 서드 파티 쿠키 없이도 작동합니다. 콘솔의 **Integration code**(연동 코드) 패널에 두 코드 조각이 브랜치의 URL로 채워져 있습니다. TODO 예제의 README에는 콜백 페이지의 규칙이 나와 있습니다(다른 어떤 작업보다 먼저 `completeSocialSignIn`을 실행해야 하며, 로그인은 같은 출처에서 시작하고 끝나야 합니다).

### 팀, 역할, 권한

사용자에게 활성 조직이 있으면 JWT에 `o` 클레임이 담깁니다.

```json
"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 호출이 401을 반환함                           | `RELYT_AUTH_ISSUER`가 토큰을 발급한 출처와 다른 출처임(예: 다른 브랜치의 인증 URL)                       |
| API가 503을 반환함                                               | 서버에서 JWKS를 가져오지 못함. 서버에서 JWKS URL로 나가는 네트워크 접근을 확인하세요                            |
| 데이터베이스 연결이 실패하거나 멈춤                                         | 연결 문자열에 `?sslmode=require`가 없음                                                   |
| 쿼리에 필터가 빠지면 사용자끼리 서로의 행을 볼 수 있음                             | 앱이 `BYPASSRLS`가 있는 역할로 연결됨(콘솔에서 만든 사용자와 `relytdb_owner`에 있음). 4단계의 역할을 사용하세요     |
| 쿼리가 행을 전혀 반환하지 않음                                           | 쿼리와 같은 트랜잭션에서 `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/ko/managed-auth-preview)를 읽어 보세요.

[무료 프로젝트 만들기](/auth/signin?ref=blog-managed-auth-getting-started)
