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 예제에서도 그대로 작동합니다. 이 예제는 같은 패턴에 더 많은 기능(소셜 로그인, 팀과 역할, 테스트 모음)을 더한 것입니다. 관리형 인증이 왜 이런 방식으로 작동하는지 배경을 알고 싶다면 먼저 Relyt One 관리형 인증, 미리 보기 공개를 읽어 보세요.
- 행마다 소유자가 하나인, 행 수준 보안으로 보호되는
notes테이블. - 호출자의 노트를 나열하고 만드는 Next.js 라우트
/api/notes.Authorization: Bearer <jwt>만 받습니다. - 인증 URL에서 사용자를 로그인시키고 새 JWT로 API를 호출하는 브라우저 헬퍼.
두 가지 규칙이 전체 설계를 지탱하며, 아래의 모든 단계는 이 규칙에서 나옵니다.
- 사용자 ID는 검증된 JWT에서만 가져옵니다. 헤더, 쿼리 문자열, 요청 본문에서는 절대 가져오지 않습니다.
- 데이터베이스도 확인합니다. API가 사용자로 필터링하고 RLS가 한 번 더 필터링하므로,
WHERE를 깜빡해도 다른 사람의 행이 유출되지 않습니다.
콘솔에서 프로젝트를 열고 앱이 사용할 브랜치를 고르세요. 개발 중에는 main의 하위 브랜치가 좋은 선택입니다. 하위 브랜치의 인증 설정, 세션, 서명 키는 그 브랜치만의 것이므로 실험이 프로덕션 로그인에 영향을 주지 않습니다. (main에서 인증을 활성화한 뒤에 하위 브랜치를 만들면 main의 신뢰할 수 있는 출처와 최종 사용자의 사본을 가진 채 시작하므로, 실제 사용자 데이터가 들어 있는 것으로 취급하세요.)
Auth(인증) 탭으로 이동해 Enable auth(인증 활성화)를 선택하세요. 처음 설정할 때는 브랜치의 컴퓨트를 깨우고 인증 테이블을 만듭니다. 약 1분이 걸리니 페이지를 열어 두세요. 이 작업은 조직 소유자와 관리자만 할 수 있습니다.
상태가 Ready(준비됨)로 바뀌면 개요에 두 개의 URL이 표시됩니다.
- Auth URL(인증 URL): 사용자가 로그인하는 곳이며, 백엔드가 토큰 발급자로 기대하는 값입니다.
- JWKS URL: 백엔드가 토큰 서명을 확인하는 데 사용하는 공개 키입니다.
둘 다 복사해 두세요. 이제 사용자, 세션, 조직은 이 브랜치 데이터베이스의 relyt_auth 스키마에 저장됩니다.
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 앱을 하나씩 사용하세요.
인증 URL은 앱과 다른 출처이므로, 앱이 누군가를 로그인시킬 때마다 브라우저는 CORS 요청을 보냅니다. 인증 서비스는 여러분이 등록한 출처에만 응답합니다.
Redirects & origins(리디렉션 및 출처)에서 앱의 정확한 출처를 Trusted origins(신뢰할 수 있는 출처)에 추가하세요.
- 개발 중에는
http://localhost:3000 - 나중에는
https://app.example.com(또는 배포하는 곳)
경로, 끝의 슬래시, 와일드카드는 쓰지 않습니다. 나중에 소셜 로그인을 추가한다면 로그인을 마무리하는 페이지(예: http://localhost:3000/auth/callback)도 Callback URLs(콜백 URL)에 등록하세요. 콜백 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는 소유자에게BYPASSRLS가 없는 한 테이블 소유자에게도 RLS가 적용되게 합니다. 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단계의 인증 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를 켜지 않습니다.
모든 데이터베이스 접근을 하나의 함수 뒤에 두세요. 이 함수는 트랜잭션을 열고, 검증된 사용자 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의 트랜잭션 풀링 뒤에서도 마찬가지입니다.
@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는 토큰에서 온 것입니다.
브라우저는 두 곳과 통신합니다. 로그인을 위한 인증 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로 요청을 보냅니다. 응답의set-auth-token헤더에 세션 토큰이 담겨 옵니다. 이 토큰이 곧 사용자의 세션입니다. 토큰을 가진 사람은 세션이 끝날 때까지 그 사용자로서 JWT를 받고 인증 URL의 엔드포인트를 호출할 수 있으므로, 비밀번호처럼 다루세요.authClient.getToken()은 세션 토큰을 JWT(15분, EdDSA)로 교환해 캐시하고, 만료되기 약 2분 전에 갱신합니다.api()는 JWT를 API로 보냅니다. 브라우저는 토큰을 검증하지 않습니다. 검증은 서버의 일입니다.
세션 토큰을 localStorage에 두면 이 가이드가 짧아지지만, 그곳에 저장된 것은 페이지에서 실행되는 모든 스크립트가 읽을 수 있으므로 크로스 사이트 스크립팅 버그가 있으면 노출됩니다. 로그인을 처리하는 페이지에는 서드 파티 스크립트를 두지 말고 엄격한 콘텐츠 보안 정책(CSP)을 사용하세요. TODO 예제는 페이지 탐색용으로 JWT를 HttpOnly 쿠키에도 넣는, 더 견고한 구성을 보여 줍니다.
시험해 볼 최소한의 페이지는 다음과 같습니다.
// 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를 실행하고, 두 개의 브라우저 프로필에서 사용자 두 명을 가입시킨 뒤 각각 노트를 추가하세요. 각 사용자는 자신의 노트만 봅니다.
브라우저에서 확인하는 것도 좋지만, 무언가 빠졌을 때 접근이 거부되는 것을 보는 편이 더 확실합니다. 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를 사용해 콜백 페이지에서 로그인을 마무리하므로 서드 파티 쿠키 없이도 작동합니다. 콘솔의 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 호출이 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 예제를 클론하세요.
createAuthClient,getClaims,createVerifier의 모든 옵션은@relyt/authREADME를 읽어 보세요.- 설계와 보안 모델은 Relyt One 관리형 인증, 미리 보기 공개를 읽어 보세요.



