1. 왜 ChatGPT App에 인증이 필요한가
핵심부터 시작하겠습니다: ChatGPT의 사용자 ≠ 여러분 서비스의 사용자.
ChatGPT에는 자체 사용자 계정이 있습니다. 여러분의 서비스에는 자체 userId, tenantId, 역할, 결제, 주문이 있습니다. 기본적으로 그 둘 사이에 마법 같은 연결은 없습니다. MCP 서버를 띄우고 몇 가지 tools를 정의해 두기만 하면, ChatGPT는 그것들을 어떤 추상적인 클라이언트로서 호출하게 됩니다.
우리의 예시 앱 GiftGenius — 선물 추천과 위시리스트 관리를 돕는 ChatGPT App — 을 떠올려 봅시다. 우리가 하고 싶은 일:
- 사용자에게 저장된 선물 목록을 보여주기.
- 선물을 “구매함” 또는 “수령함”으로 표시하기.
- 주문 내역 보여주기(나중에 commerce/ACP로 확장할 경우 특히 중요).
인증이 없으면 MCP 서버는 “이 사람이 누구인지” 전혀 알 수 없습니다. 최대한 볼 수 있는 것은 연결의 기술적 식별자들과 OpenAI가 rate limit 등을 위해 제공하는 익명 subject 정도입니다. 하지만 이는 권한 부여에 사용하지 말라고 명확히 안내되어 있습니다.
인증 vs 인가
두 개념을 처음부터 나눠 생각하는 것이 매우 유용합니다.
- 인증(AuthN)은 질문에 답합니다: 누구인가?
- 인가(AuthZ)는 질문에 답합니다: 이 “누구”에게 무엇을 허용할 것인가?
ChatGPT App에서는 대략 다음과 같습니다:
- 먼저 OAuth를 통해 사용자가 여러분의 IdentityProvider(IdP)(예: Keycloak/Auth0)에 실제로 로그인했음을 확인하고, 그 사용자의 식별자를 담은 토큰을 받습니다. 이것이 인증입니다.
- 이후 MCP 서버는 토큰을 읽어 sub, 역할과 기타 claims를 꺼내고, 특정 도구(list_orders, delete_profile 등)를 이 사용자가 호출할 수 있는지 결정합니다. 이것이 인가입니다.
코드 수준에서는(단순화하여) 이렇게 표현할 수 있습니다:
// MCP 서버가 사용자에 대해 알고 싶어하는 데이터 타입
export interface AuthContext {
userId: string;
roles: string[];
}
// tool 핸들러에서의 사용 예
async function listGiftLists(auth: AuthContext | null) {
if (!auth) {
throw new Error("User is not authenticated");
}
// DB에서 이 사용자에 해당하는 목록만 가져온다
return db.giftLists.findMany({ where: { ownerId: auth.userId } });
}
userId와 역할이 없으면 비즈니스 로직을 제대로 작성할 수 없습니다. 모든 것이 “모두가 하나의 공용 계정”인 상태로 전락합니다.
2. 왜 “.env에 API 키”가 해답이 아닌가
개발자로서 우리의 자연스러운 반응은 이렇습니다: “API 키를 만들고 .env에 넣으면 다 될 거야.” 실제로 서비스 간 내부 통합에서는 API 키가 정상적인 도구입니다. 하지만 실제 사용자와 ChatGPT App이 등장하는 순간, “모두가 하나의 키” 접근은 무너집니다.
초기 모듈에서 MCP가 여러분의 백엔드로 단순 호출하던 전형적인 코드를 보겠습니다:
// mcp/backendClient.ts
export const backendClient = new BackendClient({
baseUrl: process.env.BACKEND_URL!,
apiKey: process.env.BACKEND_API_KEY!, // ChatGPT 전체에 하나의 키
});
백엔드 관점에서 이제 모든 요청은 동일하게 보입니다: “이건 ChatGPT 통합이야.” Masha와 Pasha 사이에 어떤 차이도 없습니다. 즉:
- “마이페이지”를 보여줄 수 없음 — 서버는 그 페이지가 누구의 것인지 알지 못합니다.
- 권한 분리가 불가: “이 사용자는 읽기만, 저 사용자는 구매까지.”
- 주문을 사람(계정)에 연결할 수 없습니다 — 여러분의 핵심 시스템에서요.
MCP 세계에서는 이것이 보안상 더 위험합니다. 사양은 Streamable HTTP를 통한 HTTP 인증(Bearer, API 키 등) 사용을 권장하지만, 보호된 리소스에 대한 사용자 접근은 하나의 서비스 키가 아니라 OAuth와 토큰을 통해 구축하는 것이 더 바람직하다고 강조합니다.
또한 OpenAI 정책 관점에서, 좋은 앱은 실제로 필요한 데이터만 요청하고, 사용자가 App과 공유하는 내용을 통제할 수 있어야 합니다. 이는 OAuth scopes 모델과 완벽히 맞아떨어지지만, “모든 것을 할 수 있는 슈퍼 키 하나” 접근과는 전혀 어울리지 않습니다.
ChatGPT 맥락에서 서비스 키가 나쁜 이유
서비스용 API 키는 서비스의 정체성을 나타냅니다. MCP 서버에서 내부 서비스나 외부 API(OpenAI API 등)를 호출할 때 서명하는 데 사용할 수는 있지만, “이 사람은 Vasya이고, 그의 주문 내역을 보여줘”라고 말할 수는 없습니다.
가장 단순한 안티 패턴 예:
// 나쁜 예: 사용자를 "속이는" 방식
async function getMyOrdersFromBackend() {
// MCP 서버가 backend의 /orders/me를 호출
const res = await fetch(`${BACKEND_URL}/orders/me`, {
headers: {
Authorization: `Bearer ${process.env.BACKEND_API_KEY}`,
},
});
// backend는 "me"를 사람 사용자가 아니라 특정 통합 서비스로 간주한다
return res.json();
}
익명 userId 같은 값을 요청 본문에 억지로 넣어 보더라도, 여전히 “조악한 자작 자전거”에 불과합니다. 어차피 필요해질 것입니다:
- 백엔드에 “이 사람이 정말로 Vasya다”를 증명하는 신뢰 가능한 방법.
- 특정 사용자의 권한을 제한하는 방법.
- 전체가 아닌 특정 사용자의 접근을 철회(revoke)하는 메커니즘.
바로 여기서 OAuth가 등장합니다.
3. 미니 용어집: 로그인 시스템에서 우리가 원하는 것
OAuth의 역사로 뛰어들기 전에, ChatGPT App에 “정상적인” 인증 시스템을 위해 요구 사항을 먼저 정리해 봅시다.
우리가 원하는 방식은 다음과 같습니다:
- 외부 IdP(Keycloak, Auth0, Hydra+Kratos 등)가 실제 사용자를 알고 있어야 합니다: 로그인, 이메일, userId, 필요하면 tenant까지.
- 이 IdP가 수명이 짧은 토큰을 발급하고, ChatGPT가 이를 HTTP 헤더 Authorization: Bearer <token>로 MCP 서버에 안전하게 전달할 수 있어야 합니다.
- MCP 서버는 토큰의 서명을 검증하고, issuer, audience, 만료와 scopes를 확인하며, sub(사용자 식별자)를 꺼내 그에 따라 사용자를 자체 엔터티(accountId, tenantId)로 매핑해야 합니다.
- 동일한 scopes를 이용해 권한을 세밀하게 제어할 수 있어야 합니다: 어떤 토큰은 read:gifts만, 다른 토큰은 write:gifts나 checkout까지.
- 토큰이 없거나 scopes가 맞지 않으면, 서버는 _meta["mcp/www_authenticate"] 를 포함한 오류를 반환하여 ChatGPT가 사용자에게 인가 UI를 표시하고/또는 토큰을 재발급받도록 해야 합니다.
요컨대 우리는 이 모든 것을 지원하는 표준적이고, 시간 검증을 거친 프로토콜이 필요합니다. 스포일러: 그것이 바로 OAuth 2.1입니다(및 그 전후 세대).
4. OAuth의 간단한 진화: 초기부터 PKCE까지
이제 RFC에 깊이 파고들지는 않되, 왜 최신 패턴들이 중요한지 이해할 수 있도록 OAuth의 진화를 차분히 훑어보겠습니다.
OAuth 1.0 / 1.0a: 암호화 체조
역사적으로 최초는 OAuth 1.0이었습니다. 이는 웹사이트가 사용자 비밀번호를 건네주지 않고도 다른 서비스에 리소스 접근을 허용하게 해 주었습니다(이것만으로도 이미 괜찮았습니다). 하지만:
- 요청 서명이 복잡했습니다: 거의 모든 요청에 HMAC 서명이 필요하고, base 문자열과 파라미터 정규화가 뒤섞였습니다.
- 매 요청을 서명해야 했고, consumer secret을 보관하며, 올바른 서명을 생성할 수 있어야 했습니다.
대부분의 현대 개발자들은 이런 번거로운 수작업을 반복하고 싶어하지 않습니다.
1.0a 사양이 몇몇 취약점을 보완했지만, 전반적인 복잡함은 남았습니다.
OAuth 2.0: “하나의 프로토콜”이 아닌 프레임워크
OAuth 2.0은 삶을 많이 단순화했습니다. 단일하고 엄격한 스킴 대신 여러 flows (authorization code, implicit, resource owner password, client credentials 등)가 생겼습니다. 유연성을 주었지만, 구현의 동물원을 낳기도 했습니다.
장점:
- SPA, 모바일, 서버 애플리케이션 통합이 쉬워졌습니다.
- 역할이 명확히 분리되었습니다: Resource Owner, Client, Resource Server, Authorization Server.
단점:
- 현실에서는 위험한 “지름길”이 많아졌습니다. 브라우저에서 코드 교환 없이 바로 토큰을 받는 implicit flow는 안전하지 않은 것으로 드러났습니다.
- password grant(클라이언트가 사용자의 로그인/비밀번호를 보내고 토큰과 교환하는 방식)는 OAuth의 철학에 반하고, 안티 패턴이 되었습니다.
사양 자체가 “선택지”를 너무 많이 남겨두어, 별도 RFC와 블로그에 권고와 모범사례가 난립했습니다.
OAuth 2.1: 다시 모아 정리
OAuth 2.1은 그 시점까지 커뮤니티에 자리 잡은 모범사례를 문서화하려는 시도입니다:
- Authorization Code Flow에 거의 전적으로 초점을 맞춥니다.
- PKCE(Proof Key for Code Exchange)가 public 클라이언트(예: 모바일, SPA, 그리고… ChatGPT/MCP 클라이언트)에 의무입니다 — 이들은 secret을 안전하게 보관할 수 없기 때문입니다.
- implicit, password grant 같은 구식·비안전 플로우는 사양에서 제외되었습니다.
- access token의 짧은 수명과, 장기 세션을 위한 refresh token 사용 권고가 포함됩니다.
이게 왜 중요할까요? MCP와 ChatGPT 생태계는 바로 이 모범사례를 지향하기 때문입니다. Apps SDK와 MCP Authorization 사양은 인가 코드 + PKCE, 짧은 수명의 토큰, 그리고 올바른 scopes를 명시적으로 요구합니다.
5. 왜 ChatGPT App 세계에서는 OAuth 2.1 + PKCE 패턴으로 사고하는가
이제 역사적 배경을 알았으니, ChatGPT와 MCP의 관점에서 보겠습니다.
Public client로서의 ChatGPT
ChatGPT(및 MCP Jam 같은 클라이언트)는 여러분의 Auth Server 관점에서 전형적인 public client입니다:
- 신뢰성 있게 보관할 client_secret이 없고 가질 수도 없습니다.
- 여러분이 통제하지 않는 OpenAI 인프라에서 실행됩니다.
따라서 유일하게 합리적인 선택은 Authorization Code Flow + PKCE입니다. 여기서 보안은 클라이언트 시크릿이 아니라, 코드 챌린지와 코드 베리파이어 검증에 기반합니다.
공식 Apps SDK 문서는 ChatGPT가 MCP 클라이언트로서 Authorization Code + PKCE(S256) 플로우를 수행하며, 여러분의 Authorization Server가 메타데이터에 PKCE 지원을 선언하지 않으면 인가를 완료하지 않는다고 명시합니다: code_challenge_methods_supported: ["S256"].
MCP 관점에서의 플로우는 어떻게 보이나
아주 거칠게나마, 보호된 리소스에 대한 시퀀스를 이렇게 상상하면 도움이 됩니다:
sequenceDiagram
participant U as 사용자
participant C as ChatGPT (MCP Client)
participant AS as Auth Server
participant RS as MCP Server (Resource)
U->>C: "내 주문을 보여줘"
C->>RS: call_tool(list_orders) 토큰 없이
RS-->>C: 오류 + _meta["mcp/www_authenticate"]
C->>AS: 로그인/동의 화면 오픈 (Authorization Code + PKCE)
U->>AS: 로그인하고 동의 제공(scopes)
AS-->>C: Authorization Code
C->>AS: 코드를 Access Token으로 교환(+PKCE 검증)
AS-->>C: Access Token (Bearer)
C->>RS: call_tool(list_orders) with Authorization: Bearer <token>
RS->>RS: 서명, issuer, audience, scopes 검증
RS-->>C: 사용자의 주문 목록
C-->>U: 데이터 표시
서버는 다음을 사용합니다:
- 보호된 리소스 메타데이터(/.well-known/oauth-protected-resource) — 여기서 자신을 리소스로 선언하고, 어떤 Authorization Server가 이 리소스를 담당하는지 지정합니다.
- 헤더 Authorization: Bearer <token> 로 전달되는 토큰 — JWK로 JWT를 검증하거나, Auth Server를 통해 introspection 할 수 있습니다.
- 토큰이 audience나 scopes가 맞지 않으면 — 서버는 요청을 거절하고 다시 WWW-Authenticate 챌린지를 _meta["mcp/www_authenticate"] 로 보내 ChatGPT가 필요한 파라미터로 인가를 다시 진행하도록 할 수 있습니다.
여러분의 코드 관점에서는 충분히 사람답습니다. 이미 검증된 AuthContext를 입력으로 받고 그와 함께 작업하면 됩니다.
미니 예제: MCP-tool이 익명 사용자와 인증된 사용자를 구분하는 방법
아직 특정 OAuth SDK는 쓰지 않고, 개념만 보겠습니다:
import type { McpToolHandler } from "./types";
export const listOrders: McpToolHandler = async (_args, context) => {
const auth = context.auth; // 여기 토큰 검증 결과가 들어간다고 가정
if (!auth) {
return {
content: [{ type: "text", text: "주문을 보려면 로그인해야 합니다." }],
_meta: {
// ChatGPT에게 보내는 챌린지: OAuth 플로우를 시작하라
"mcp/www_authenticate": [
'Bearer resource_metadata="https://mcp.giftgenius.app/.well-known/oauth-protected-resource", error="insufficient_scope", error_description="Login required to view orders"'
]
},
isError: true
};
}
const orders = await db.orders.findMany({ where: { userId: auth.userId } });
return {
content: [{ type: "text", text: `주문 건수: ${orders.length}` }],
structuredContent: orders
};
};
바로 이런 _meta["mcp/www_authenticate"] 힌트가 ChatGPT 측 OAuth UI를 트리거하는 것으로 Apps SDK 공식 문서에 설명되어 있습니다.
6. 실무에서 ‘수명이 짧은 토큰, 최소한의 scopes’란 무엇인가
사양과 가이드에서 몇 가지 중요한 원칙이 더 나옵니다. 다음 강의에서 IdP를 구체적으로 설정하기 전에 미리 머릿속에 담아 둡시다.
토큰의 짧은 수명
Access token은 짧게 살아야 합니다. 왜일까요?
- 토큰이 유출되어도, 공격자는 시간적으로 크게 제약을 받습니다.
- 여러분이 사용자 권한을 바꾸면, 짧은 시간 뒤 토큰이 “만료”되고 새 토큰을 요청하게 됩니다.
보통 수 분에서 수십 분입니다. 그 대가로 refresh token과/또는 재인가가 필요하지만, ChatGPT 컨텍스트에서는 대부분의 번거로움을 클라이언트 쪽이 처리합니다.
권한 제한 수단으로서의 Scopes
Scopes는 gifts.read, gifts.write, orders.read, orders.checkout 같은 문자열입니다. 이는 해당 리소스 범위에서 사용자가 무엇을 할 권한이 있는지를 나타냅니다.
ChatGPT App에서는 특히 중요합니다:
- 사용자가 위시리스트를 단순 열람할 때는 gifts.read만 포함된 토큰을 줄 수 있습니다.
- ACP/Instant Checkout 같은 작업에는 더 강한 권한 집합 — 예를 들어 orders.checkout — 을 요청하고, 이를 사용자에게 명확히 보여 주는 것이 타당합니다.
MCP tools 정의에서는 이미 특정 도구 호출에 필요한 securitySchemes와 scopes를 선언할 수 있어, ChatGPT가 어떤 권한이 필요한지 알 수 있습니다.
Audience: 토큰은 “이” MCP 리소스를 위한 것이어야 함
또 하나 중요 포인트는 aud(audience)입니다. MCP 서버는 토큰이 실제로 자기 자신을 위해 발급된 것인지, 다른 서비스용이 아닌지를 확인해야 합니다.
Apps SDK 문서에는 ChatGPT가 resource 파라미터를 전달하고, Authorization Server가 이를 토큰(보통 aud)에 반영하며, MCP 서버는 이 필드를 검사하리라 기대한다고 명시되어 있습니다.
앱 리뷰 과정에서 가짜 auth_token을 전달해 여러분의 보안 구현에 구멍이 없는지 확인할 확률이 큽니다. 처음부터 제대로 만드세요.
7. 이것을 우리의 GiftGenius 앱에 적용하면
우리의 학습용 App에 집중해 봅시다. 현재 대략 이런 모습입니다:
- get_gift_ideas MCP tool: 수신자 설명과 예산으로 선물 아이디어를 제안 — 익명으로도 동작 가능.
- save_gift_list MCP tool: 목록을 DB에 저장 — 특정 사용자에 연결되길 원함.
- list_saved_lists MCP tool: 사용자가 저장한 모든 목록을 표시 — 인증이 반드시 필요.
위젯은 예쁜 선물 카드를 보여 주고, “저장”, “구매 표시”를 클릭하게 합니다 — 이는 본질적으로 보호된 MCP 도구들에 대한 프런트입니다.
타입 수준에서는 이렇게 보일 수 있습니다:
// tool 호출 컨텍스트의 타입(단순화)
interface ToolContext {
auth: AuthContext | null;
}
// 보호된 도구 예시
async function listSavedGiftLists(_input: {}, context: ToolContext) {
if (!context.auth) {
// 위에서 본 것과 같은 mcp/www_authenticate 트릭을 쓸 것이다
throw new Error("Authentication required");
}
return db.giftLists.findMany({
where: { ownerId: context.auth.userId }
});
}
이런 함수를 작성하는 순간 분명해집니다. “.env에 그냥 API 키 하나”로는 아무 도움이 되지 않습니다. 검증된 OAuth 토큰을 바탕으로 구성된 진짜 AuthContext가 필요합니다.
어떤 부분은 익명으로 가능하고, 어떤 부분은 불가한가
OAuth 설정 전에 해 볼 좋은 연습은 기능을 두 가지로 솔직히 나누는 것입니다.
예: GiftGenius에서
익명 가능:
- 설명을 바탕으로 선물 아이디어 생성.
- 예시 보여주기 및 더미 데이터로 데모 모드.
인증 사용자만:
- 개인 위시리스트 조회 및 편집.
- 주문 내역.
- 결제 작업, Instant Checkout, ACP 연동 등.
다음 강의에서는 Auth Server(Keycloak 또는 Hydra+Kratos 등)와 MCP 서버를 설정하여, 이러한 작업에 필요한 scopes를 토큰에 부여하고, MCP tools가 적절히 거부하며 ChatGPT에 재인가를 요청하도록 만드는 방법을 다룹니다.
8. ChatGPT App 인증 이해에서 흔한 실수
오류 №1: “ChatGPT가 이미 사용자를 아는데, 왜 내 로그인 시스템이 필요해?”
많은 분이 이렇게 생각합니다: “ChatGPT에 사용자 계정이 있으니, 그걸 userId로 쓰면 되잖아?” 하지만 ChatGPT는 여러분에게 실제 사용자의 정체성을 공개하지 않으며, 그 계정들에 대한 접근을 주지도 않습니다. MCP 메타데이터에서 볼 수 있는 것은 기껏해야 익명 _meta["openai/subject"] 정도로, 이는 rate limit과 세션 식별용이며, 인가나 실제 계정 연결에는 사용하지 말라고 명확히 되어 있습니다.
오류 №2: “모두에게 하나의 API 키 — 통합이니 괜찮아”
“MCP 서버에 우리 백엔드용 API 키를 심어 두고 끝” 접근은 ChatGPT 사용자 전원이 여러분 서비스의 한 계정을 공유하는 시나리오에서만 작동합니다. 개인 데이터, commerce, ACL이 등장하는 순간, 여러분은 사용자를 구분하고 권한을 관리할 수 없다는 벽에 부딪힙니다. API 키는 서비스의 정체성이지, 사용자의 정체성이 아닙니다.
오류 №3: “password grant로 빨리 만들자, 가장 쉬워”
사용자의 로그인/비밀번호를 여러분의 백엔드로 보내 토큰으로 교환하는(Resource Owner Password Credentials Grant) 습관은 OAuth 2.0 초기의 구식·비안전 패턴입니다. 현대 권고, 그리고 OAuth 2.1 맥락에서는 안티 패턴으로 간주됩니다. ChatGPT 같은 public 클라이언트는 여러분 사용자의 비밀번호를 아예 보지 않아야 합니다 — 그것이 Authorization Code + PKCE가 존재하는 이유입니다.
오류 №4: “PKCE는 과한 복잡도, 그냥 빼자”
PKCE(특히 S256)는 public 클라이언트의 Authorization Code Flow를 보호하는 데 필수입니다. PKCE가 없으면 탈취된 authorization code를 재사용할 수 있습니다. MCP Authorization 사양과 Apps SDK에는 여러분의 Authorization Server 메타데이터에 PKCE 지원을 선언해야 하며, 실제로 이 메커니즘을 사용한다고 명시되어 있습니다. 이를 끄면 플로우 자체가 동작하지 않습니다.
오류 №5: “혹시 모르니 가능한 모든 scopes를 한꺼번에 요청하자”
“무엇이든 다 할 수 있는” 토큰을 만들고 싶어질 때가 있습니다. 하지만 이는 최소 권한 원칙(PoLP)에 반하며, OpenAI와 대부분의 IdP 정책에도 어긋납니다. 여러분의 ChatGPT App에 정말 필요한 scopes가 무엇인지 명확히 나누세요: 읽기용, 쓰기용, 결제용 등. 이는 보안 향상뿐 아니라 동의 UX에도 영향을 줍니다 — 사용자는 스무 개의 난해한 줄이 아닌, 이해 가능한 제한된 권한 목록을 보게 됩니다.
오류 №6: “MCP 서버가 로그인/비밀번호를 저장하고 로그인 UI도 그리자”
MCP 서버는 Resource Server이지, Auth Server가 아닙니다. 토큰을 검증하고, 자신의 .well-known 메타데이터를 선언하며, WWW-Authenticate 챌린지를 반환할 줄은 알아야 하지만, 로그인 처리나 비밀번호 보관을 담당해서는 안 됩니다. 로그인/동의를 위해서는 전문 Authorization Server(Keycloak, Hydra, Auth0 등)를 사용하세요. 이는 다음 강의에서 보게 될 것입니다.
GO TO FULL VERSION