보안은 기능이 아니라 신뢰의 조건이다
ChatGPT 안에서 실행되는 앱은 사용자의 대화 흐름 안으로 들어간다. 사용자는 앱을 별도 사이트처럼 멀리 떨어진 도구로 느끼지 않는다. 그래서 편리함은 커지지만, 보안 설계의 책임도 커진다.
예를 들어 일정 앱이 "다음 주 미팅을 잡아 줘"라는 요청을 처리한다고 하자. 이 앱은 사용자의 캘린더를 읽고, 참석자를 확인하고, 실제 일정을 생성할 수 있다. 여기서 인증이 허술하면 다른 사용자의 일정이 노출될 수 있다. 권한 범위가 과하면 앱이 필요 이상으로 많은 데이터를 가져간다. 쓰기 작업 확인이 없으면 사용자가 의도하지 않은 예약이나 결제가 발생할 수 있다.
Apps SDK의 보안 설계는 한 문장으로 정리할 수 있다. 사용자 대신 행동하는 모든 기능은 서버에서 인증하고, 필요한 권한만 요청하며, 민감한 데이터는 최소한으로 다룬다.

Apps SDK 인증 흐름의 기본 구조
Apps SDK 앱은 MCP 서버를 중심으로 동작한다. ChatGPT는 사용자의 요청을 해석해 MCP 도구를 호출하고, MCP 서버는 실제 외부 서비스와 연결된다. 인증도 이 흐름 안에서 처리된다.
인증이 필요한 앱은 먼저 MCP 서버가 "이 리소스는 보호되어 있으며, 어떤 인증 서버와 어떤 권한 범위를 사용한다"는 정보를 공개해야 한다. 이를 위해 OAuth 보호 리소스 메타데이터와 도구별 securitySchemes를 선언한다. ChatGPT는 이 정보를 보고 계정 연결이 필요한 도구인지 판단한다.1
도구 호출 시 토큰이 없거나, 만료되었거나, 권한이 부족하면 MCP 서버는 모호한 성공 응답을 주면 안 된다. 명확히 401 Unauthorized로 응답하고 WWW-Authenticate 챌린지를 포함해야 한다. 이 응답은 ChatGPT에게 "사용자 인증 흐름을 다시 시작해야 한다"는 신호가 된다.2
간단한 도구 선언은 다음처럼 생각할 수 있다.
server.registerTool("create_event", {
description: "사용자의 캘린더에 새 일정을 생성합니다.",
inputSchema: {
type: "object",
properties: {
title: { type: "string" },
startsAt: { type: "string", format: "date-time" },
attendeeEmail: { type: "string" }
},
required: ["title", "startsAt"]
},
securitySchemes: [
{
type: "oauth2",
scopes: ["calendar.write"]
}
]
}, async (input, context) => {
const user = context.auth?.user;
if (!user) {
throw unauthorizedChallenge();
}
await assertScope(context.auth, "calendar.write");
return createCalendarEvent(user.id, input);
});핵심은 선언과 검증을 분리하지 않는 것이다. securitySchemes는 ChatGPT가 연결 흐름을 이해하도록 돕는 계약이다. 하지만 최종 판단은 항상 MCP 서버가 해야 한다. 클라이언트가 "인증된 사용자일 것"이라고 믿고 실행하면 안 된다.
OAuth 2.1 계정 연결과 요청 컨텍스트
Apps SDK에서 일반적인 계정 연결은 OAuth 2.1 기반으로 설계한다. 사용자는 ChatGPT 안에서 앱 연결을 시작하고, 앱의 인증 서버에서 로그인과 동의를 마친다. 이후 ChatGPT는 도구 호출마다 액세스 토큰을 전달하고, MCP 서버는 이 토큰을 검증한다.
서버에서 해야 할 일은 세 가지다.
첫째, 토큰의 발급자, 만료 시간, 대상, 서명을 검증한다. JWT라면 JWKS를 통해 서명을 확인하고, opaque token이라면 introspection 엔드포인트를 통해 유효성을 확인할 수 있다.
둘째, 검증된 사용자 식별자를 요청 컨텍스트에 붙인다. 예를 들어 context.userId, context.accountId, context.scopes처럼 이후 비즈니스 로직이 일관되게 참조할 값을 만든다.
셋째, 실패를 정확히 구분한다. 토큰이 없거나 만료되었으면 401이다. 토큰은 유효하지만 권한 범위가 부족한 경우에도 ChatGPT가 재동의 흐름을 시작할 수 있도록 인증 챌린지를 돌려주는 편이 안전하다. 반대로 입력값 오류나 존재하지 않는 리소스는 400, 404 등 다른 오류로 분리한다.
권한 범위는 크게 읽기, 쓰기, 관리로 나누는 것이 좋다.
profile.read: 이름, 이메일처럼 기본 프로필 읽기calendar.read: 일정 목록 조회calendar.write: 일정 생성·수정calendar.admin: 공유 설정, 연결 해제, 조직 단위 변경
처음부터 calendar.admin을 요청하면 사용자는 불안해진다. 앱도 위험해진다. 첫 연결에서는 읽기 권한만 요청하고, 사용자가 실제로 일정을 생성하려는 순간 쓰기 권한을 요청하는 방식이 더 낫다. 이것이 최소 권한 원칙이다.3
위험한 작업에는 한 번 더 멈춤을 둔다
AI 앱의 쓰기 작업은 일반 웹 앱보다 더 조심해야 한다. 사용자가 버튼을 직접 누른 것이 아니라 자연어로 요청했기 때문이다. "괜찮은 시간으로 예약해 줘"라는 말에는 여러 해석이 가능하다. 앱이 바로 일정을 생성하면 사용자는 통제권을 잃었다고 느낄 수 있다.
따라서 다음 작업에는 추가 확인 단계를 둔다.
결제, 주문, 예약 확정
이메일·메시지 발송
파일 삭제 또는 외부 공유
권한 변경
공개 게시물 작성
건강, 금융, 법률 정보 전송
도구 설계에서도 이를 드러내야 한다. 예를 들어 draft_email과 send_email을 분리한다. draft_email은 읽기 또는 임시 생성 작업이고, send_email은 실제 발송 작업이다. 사용자가 내용을 확인한 뒤에만 send_email이 실행되도록 만든다.
또한 쓰기 도구는 멱등성 키를 받는 것이 좋다. 멱등성 키는 같은 요청이 두 번 들어와도 한 번만 처리하기 위한 식별자다. 네트워크 재시도나 모델의 반복 호출 때문에 중복 주문, 중복 예약이 생기는 일을 줄일 수 있다.
위젯에서 토큰을 직접 다루지 않는다
위젯은 ChatGPT 안의 샌드박스 iframe에서 실행되는 UI다. React나 일반 JavaScript로 버튼, 목록, 지도, 차트 같은 화면을 만들 수 있다. 이때 흔한 유혹이 있다. "OAuth 토큰을 위젯에서 꺼내 직접 백엔드 API를 호출하면 빠르지 않을까?"
기본 원칙은 반대다. 위젯은 토큰을 직접 소유하지 않는 편이 안전하다. 인증된 작업은 window.openai.callTool 같은 호스트 API를 통해 MCP 도구로 보내고, MCP 서버가 토큰 검증과 권한 판단을 수행하게 한다.
이 구조의 장점은 분명하다.
첫째, 토큰 노출면이 줄어든다. 브라우저 번들, 개발자 도구, 서드파티 스크립트, XSS 취약점에 토큰이 노출될 가능성을 낮춘다.
둘째, 모든 중요한 작업이 MCP 서버 로그에 남는다. 누가, 언제, 어떤 도구를, 어떤 권한으로 호출했는지 감사할 수 있다.
셋째, ChatGPT가 도구 호출 결과를 대화 컨텍스트와 더 잘 연결할 수 있다. 위젯이 별도 API를 직접 호출하면 화면은 바뀌었지만 모델은 그 변화를 모를 수 있다. 반면 MCP 도구 호출은 대화형 앱의 상태 흐름 안에 남는다.
직접 fetch가 항상 금지되는 것은 아니다. 공개 정적 리소스, 비인증 검색, 이미지 CDN 접근처럼 위험이 낮은 요청에는 사용할 수 있다. 하지만 사용자 계정에 연결된 읽기·쓰기 작업이라면 MCP 서버를 통과시키는 것을 기본값으로 삼아야 한다.
iframe, CORS, CSP는 운영 전환의 핵심이다
Apps SDK 위젯은 샌드박스 iframe 안에서 실행된다. 샌드박스는 위젯이 ChatGPT 호스트 페이지에 함부로 접근하지 못하도록 막는 브라우저 경계다. 하지만 샌드박스가 모든 것을 대신 해결해 주지는 않는다. 운영 환경에서는 네트워크 경계를 명확히 고정해야 한다.
먼저 CORS를 설정한다. CORS는 다른 출처의 웹 페이지가 내 서버에 요청할 수 있는지를 브라우저가 판단하는 규칙이다. 개발 중에는 localhost, 터널 도메인, 정적 파일 서버가 섞이기 쉽다. 운영에서는 허용 출처를 구체적인 도메인으로 제한한다. *를 편하게 열어 두면 인증 요청과 사용자 데이터 요청의 경계가 흐려진다.
다음으로 CSP를 설정한다. CSP는 위젯이 스크립트, 이미지, 스타일, API 요청을 어느 도메인에서 불러올 수 있는지 제한하는 정책이다. 위젯이 필요한 connect 도메인과 resource 도메인을 미리 정하고, 그 외 도메인은 차단한다.
또한 로컬 개발용 호스트와 운영 호스트를 분리한다. 예제 프로젝트에서는 ngrok 같은 터널을 사용해 로컬 서버를 ChatGPT에 연결할 수 있다. 이때 Python MCP SDK는 DNS rebinding 방어를 위해 허용 호스트와 허용 오리진을 명시하도록 요구한다.4 이 설정을 운영 서버에 그대로 가져가면 안 된다. 개발용 터널 도메인, 내부망 주소, 임시 포트는 운영 허용 목록에서 제거해야 한다.
운영 전환 전에는 다음을 확인한다.
MCP 서버는 HTTPS로만 접근 가능한가?
OAuth 콜백 URL은 운영 도메인으로 고정되어 있는가?
위젯 정적 자산 도메인과 API 도메인이 분리되어 있는가?
CORS 허용 출처에 와일드카드가 없는가?
CSP가 외부 스크립트 삽입을 막는가?
개발용 터널 도메인이 남아 있지 않은가?
비밀값은 코드와 프롬프트에 넣지 않는다
API 키, OAuth 클라이언트 시크릿, 세션 서명 키, 데이터베이스 비밀번호는 모두 비밀값이다. 이 값들은 코드 저장소, 프런트엔드 번들, 위젯 HTML, 시스템 프롬프트, 도구 설명에 들어가면 안 된다.
비밀값은 환경 변수나 클라우드 비밀 관리 서비스로 주입한다. 운영에서는 서비스별로 권한이 제한된 키를 사용한다. 가능하다면 키 대신 관리형 ID나 역할 기반 접근 제어를 사용한다. 키가 없으면 유출될 키도 줄어든다. Azure OpenAI의 경우 관리 ID와 RBAC를 사용해 API 키 없이 리소스에 접근하는 구성이 권장된다.5
키 관리에는 세 가지 습관이 필요하다.
첫째, 권한을 줄인다. 모든 기능이 가능한 마스터 키를 앱 서버 전체에 넣지 않는다. 읽기 전용 키, 특정 프로젝트 키, 특정 환경 키로 나눈다.
둘째, 회전할 수 있게 만든다. 키를 바꾸려면 배포가 필요한 구조는 위험하다. 환경 변수나 비밀 관리 서비스에서 교체하고 재시작만으로 반영되도록 만든다.
셋째, 로그에서 가린다. Authorization 헤더, 쿠키, 토큰, 이메일, 전화번호, 주소는 기본적으로 마스킹한다. 오류 분석을 위해 원문 전체를 남기는 습관은 개인정보 사고로 이어질 수 있다.
민감 데이터는 목적별로 최소화한다
Apps SDK 앱은 대화 컨텍스트, 외부 서비스 데이터, 위젯 상태를 함께 다룬다. 이 조합은 강력하지만 민감하다. 위치, 구매 내역, 건강, 금융, 직장 문서, 가족 정보처럼 개인의 삶을 드러내는 데이터는 특별히 조심해야 한다.
개인정보 설계는 다음 질문에서 시작한다.
이 데이터가 지금 기능에 꼭 필요한가?
사용자가 어떤 목적으로 쓰이는지 이해할 수 있는가?
같은 목적을 더 적은 데이터로 달성할 수 있는가?
저장하지 않고 즉시 처리할 수 있는가?
사용자가 나중에 철회하거나 삭제를 요청할 수 있는가?
예를 들어 맛집 추천 앱이 "현재 위치"를 요청할 수는 있다. 하지만 정확한 GPS 좌표를 장기간 저장할 필요는 없을 수 있다. 동 단위 위치나 사용자가 입력한 지역명만으로 충분하다면 그렇게 설계한다. 건강 앱이 검사 결과를 분석한다면, 원본 파일을 저장하지 않고 세션 중에만 처리하는 방식도 고려해야 한다.
도구 결과에도 개인정보가 섞일 수 있다. structuredContent에는 모델이 읽어도 되는 요약 데이터를 넣고, 위젯 전용 세부 정보는 _meta에 넣는 식으로 노출 범위를 조절한다. 그러나 _meta도 완전한 비밀 저장소가 아니다. 프런트엔드로 전달되는 데이터라면 사용자가 볼 수 있다고 가정해야 한다.
앱 제출을 준비한다면 공개된 개인정보 처리방침도 필요하다. 어떤 종류의 개인 데이터를 수집하는지, 어떤 목적으로 쓰는지, 누구와 공유하는지, 얼마나 보관하는지, 사용자가 어떤 통제권을 갖는지 설명해야 한다.6
서버가 최종 게이트키퍼다
Apps SDK 보안에서 가장 중요한 문장은 이것이다. MCP 서버가 최종 게이트키퍼다.
ChatGPT가 인증 흐름을 시작해 주고, 위젯이 샌드박스에서 실행되고, securitySchemes가 권한 요구사항을 설명해도, 실제 보호는 서버에서 완성된다. 서버는 매 요청마다 토큰을 검증하고, 사용자 식별자를 확인하고, 스코프를 검사하고, 리소스 소유권을 확인해야 한다.
특히 리소스 소유권 검사를 빼먹기 쉽다. calendar.write 권한이 있다고 해서 모든 캘린더에 쓸 수 있는 것은 아니다. 사용자가 소유하거나 위임받은 캘린더에만 쓸 수 있어야 한다. document.read 권한이 있다고 해서 조직의 모든 문서를 읽을 수 있는 것도 아니다.
다음 순서로 요청을 처리하면 실수를 줄일 수 있다.
입력 JSON Schema 검증
액세스 토큰 검증
사용자 식별자 확인
도구에 필요한 스코프 확인
대상 리소스의 소유권 또는 접근 권한 확인
위험 작업의 사용자 확인 여부 확인
실행
개인정보를 줄인 결과 반환
민감값을 제거한 감사 로그 기록
이 흐름이 반복 가능해야 한다. 도구마다 임의로 인증 코드를 복사해 붙이면 누락이 생긴다. 공통 미들웨어나 헬퍼로 만들고, 도구에는 "이 도구가 요구하는 권한"만 선언하게 하는 구조가 좋다.
출시 전 보안 체크리스트
마지막으로 실제 앱을 배포하기 전 다음 항목을 점검하자.
인증이 필요한 모든 도구에
securitySchemes가 선언되어 있는가?토큰 누락, 만료, 검증 실패 시
401과WWW-Authenticate를 반환하는가?읽기, 쓰기, 관리 권한이 분리되어 있는가?
위험한 쓰기 작업에 사용자 확인 단계가 있는가?
위젯이 OAuth 토큰이나 API 키를 직접 다루지 않는가?
MCP 서버가 매 요청마다 토큰, 스코프, 리소스 소유권을 검증하는가?
CORS와 CSP가 운영 도메인 기준으로 제한되어 있는가?
개발용 터널, localhost, 임시 호스트가 운영 설정에 남아 있지 않은가?
비밀값이 코드, 프롬프트, 프런트엔드 번들, 로그에 포함되지 않는가?
개인정보 수집 목적, 보관 기간, 삭제 방법이 문서화되어 있는가?
로그와 도구 결과에서 이메일, 전화번호, 주소, 토큰이 마스킹되는가?
보안은 사용자가 알아차리지 못할 때 가장 잘 작동한다. 하지만 문제가 생기면 가장 먼저 드러난다. ChatGPT 안의 앱은 사용자와 가까운 곳에서 실행된다. 그만큼 권한은 작게, 확인은 분명하게, 데이터는 적게 다루는 설계가 필요하다.
참고
1Authentication – Apps SDK | OpenAI Developers
2Authentication and OAuth | openai/openai-apps-sdk-examples | DeepWiki
3Security & Privacy - Apps SDK | OpenAI Developers
4GitHub - openai/openai-apps-sdk-examples
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.