Published on

Responses API 401인데 키가 맞는 7가지 이유

Authors

서버에서 401 Unauthorized가 떨어지면 대부분 “키가 틀렸나?”부터 의심합니다. 그런데 OpenAI Responses API에서는 키 문자열이 맞아도 401이 나는 케이스가 꽤 많습니다. 이유는 대체로 **키를 둘러싼 컨텍스트(프로젝트, 권한, 헤더, 조직/프로젝트 스코프, 프록시, 엔드포인트)**가 어긋나기 때문입니다.

이 글에서는 운영 환경에서 자주 만나는 “키는 맞는데 401” 7가지 원인을, 재현 포인트와 함께 빠르게 좁혀가는 체크리스트 형태로 정리합니다.

> 더 넓은 인증/인가 점검 항목(401/403 전체)은 아래 글도 같이 보시면 좋습니다. > - OpenAI Responses API 401 403 인증오류 점검 가이드


0) 먼저: 401의 형태부터 분류하기

401은 크게 두 부류로 갈립니다.

  • 헤더/토큰 형식 문제: Authorization이 없거나 형식이 틀림
  • 스코프/권한 문제: 토큰은 유효하지만 이 요청에 접근할 권한/스코프가 없음(프로젝트/조직/모델 권한 등)

로그에 찍히는 에러 메시지(가능하면 error.type, error.code)를 확보한 뒤, 아래 7가지를 순서대로 확인하면 대부분 10분 안에 결론이 납니다.


1) Authorization 헤더가 “Bearer”가 아닌 다른 값으로 들어감

가장 흔한 실수입니다. 특히 프록시/게이트웨이에서 헤더를 재작성하거나, 라이브러리에서 Authorization: Token xxx 같은 관례를 쓰는 경우가 있습니다.

재현 예시(curl)

curl https://api.openai.com/v1/responses \
  -H "Authorization: Token $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4.1-mini","input":"ping"}'

올바른 형태

curl https://api.openai.com/v1/responses \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4.1-mini","input":"ping"}'

점검 포인트

  • 서버/프록시가 Authorization 헤더를 삭제하거나 다른 헤더로 이동시키지 않는지
  • 브라우저/프론트에서 호출 시 CORS/보안 정책으로 Authorization이 누락되지 않는지

2) 환경변수/시크릿이 “맞는 키”로 보이지만 실제 런타임에선 다른 값

로컬에서는 잘 되는데 스테이징/프로덕션에서만 401이면, 거의 항상 시크릿 주입/오버라이드 문제입니다.

대표 패턴:

  • Kubernetes Secret 업데이트 후 Pod 재시작이 안 됨
  • .env와 CI/CD 시크릿이 서로 다른 값인데, 런타임에서 다른 쪽이 우선 적용
  • OPENAI_API_KEY를 설정했는데 코드가 OPENAI_KEY 같은 다른 변수를 읽음

런타임에서 “실제 사용 중인 값”을 안전하게 검증하는 방법

키 전체를 로그로 찍지 말고, 앞/뒤 일부만 마스킹해서 비교합니다.

import os

def mask_key(k: str) -> str:
    if not k:
        return "<empty>"
    return k[:6] + "..." + k[-4:]

key = os.getenv("OPENAI_API_KEY", "")
print("OPENAI_API_KEY=", mask_key(key))

점검 포인트

  • 컨테이너 내부에서 printenv | grep OPENAI로 확인
  • 배포 파이프라인에서 시크릿이 다른 환경의 값으로 덮어쓰기 되는지
  • 여러 서비스가 같은 시크릿 이름을 공유하면서 의도치 않게 교체되는지

3) 프로젝트/조직 스코프 불일치(키는 유효하지만 현재 프로젝트 권한이 없음)

OpenAI 플랫폼은 프로젝트 단위 권한/RBAC가 걸려 있을 수 있습니다. 이 경우 키가 “존재하는 키”여도, 특정 프로젝트/모델/리소스에 접근 권한이 없으면 401/403 계열로 막힐 수 있습니다.

특히 팀에서 다음이 자주 발생합니다.

  • 키를 만든 사람은 A 프로젝트 멤버, 실행 환경은 B 프로젝트 설정을 가정
  • 조직 정책으로 특정 모델은 특정 프로젝트에서만 허용
  • 키가 프로젝트 제한(scoped key)인데 다른 프로젝트 리소스를 호출

해결 방향

  • 키 발급 주체(사용자/서비스 계정)가 해당 프로젝트 멤버인지
  • 프로젝트의 모델 접근 권한이 있는지
  • 필요하다면 키를 올바른 프로젝트에서 재발급

> 이 부분은 항목이 많아 별도 체크리스트가 유용합니다. > - OpenAI Responses API 401 403 인증오류 점검 가이드


4) “키는 맞다”가 사실은 ‘다른 계정/다른 조직의 키’

컨설팅/외주/협업에서 흔합니다. 개발자가 개인 계정 키로 테스트하다가, 운영에서는 회사 계정 키를 쓰거나 그 반대가 됩니다.

  • 로컬: 개인 계정 키(결제/권한 OK)
  • 서버: 회사 계정 키(권한 정책 다름, 프로젝트 멤버십 다름)

이때 개발자는 “키 문자열이 맞다”고 말하지만, 실제로는 기대하는 계정 컨텍스트가 아닌 키라 401이 납니다.

점검 포인트

  • 키가 발급된 조직/프로젝트가 무엇인지(키 관리 콘솔에서 확인)
  • 결제/사용 제한 정책이 조직 단위로 걸려 있지 않은지

5) 프록시/게이트웨이/WAF가 Authorization을 제거하거나 변조

Cloudflare, Nginx, ALB, 사내 API Gateway, WAF에서 보안 정책으로 Authorization을 제거하는 경우가 있습니다. 이때 애플리케이션은 “키를 넣어서 보냈다”고 믿지만, OpenAI에는 헤더가 없는 요청이 도착해 401이 납니다.

Nginx에서 흔한 실수

  • 업스트림으로 Authorization을 전달하지 않음
  • proxy_set_header Authorization ""; 같은 설정이 들어감

예시: Authorization 전달 명시

location /openai/ {
  proxy_pass https://api.openai.com/;
  proxy_set_header Authorization $http_authorization;
  proxy_set_header Content-Type $content_type;
}

점검 포인트

  • 프록시 액세스 로그에 Authorization이 존재하는지(가능하면 마스킹)
  • 보안 정책에서 Authorization 헤더가 허용되는지
  • HTTP/2 변환, gRPC gateway 등에서 헤더 매핑이 누락되지 않았는지

> 프록시 이슈는 401만이 아니라 499/502/504 같은 스트리밍 장애로도 이어집니다. 스트리밍을 쓰고 있다면 아래 글도 같이 점검하세요. > - LLM SSE 스트리밍 499 502 급증과 응답 끊김을 잡는 프록시 튜닝 체크리스트


6) 엔드포인트/베이스 URL이 OpenAI가 아닌 다른 곳(모킹/에뮬레이터/프록시)

키가 맞는데 401이면, “내가 정말 api.openai.com에 보내고 있나?”를 확인해야 합니다.

다음 상황에서 자주 틀어집니다.

  • OPENAI_BASE_URL을 사내 프록시로 바꿔둠
  • 로컬에서만 모킹 서버(예: localhost:8080)를 쓰도록 설정해둠
  • 멀티 클라우드 환경에서 egress proxy가 다른 도메인으로 라우팅

Python에서 실제 요청 URL을 로깅

httpx 같은 클라이언트에 이벤트 훅을 걸어 최종 URL을 확인합니다.

import os
import httpx

OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.openai.com")

def log_request(request: httpx.Request):
    print("->", request.method, request.url)

with httpx.Client(event_hooks={"request": [log_request]}) as client:
    r = client.post(
        f"{BASE_URL}/v1/responses",
        headers={"Authorization": f"Bearer {OPENAI_API_KEY}"},
        json={"model": "gpt-4.1-mini", "input": "ping"},
        timeout=30,
    )
    print(r.status_code)
    print(r.text)

점검 포인트

  • 최종 URL이 https://api.openai.com/v1/responses인지
  • 프록시를 쓴다면 그 프록시가 Authorization 전달/재서명을 제대로 하는지

7) 멀티키/멀티스레드 환경에서 “가끔” 틀린 키가 섞여 들어감

서버가 여러 테넌트를 처리하거나, 요청별로 키를 바꿔 끼우는 구조(예: 사용자별 BYOK, 라우팅)라면 401이 간헐적으로 발생합니다.

대표 원인:

  • 전역 변수에 키를 저장하고 요청마다 덮어씀(레이스 컨디션)
  • 커넥션 풀/세션 객체를 공유하면서 헤더가 재사용됨
  • 캐시된 설정이 갱신되며 일부 워커만 다른 키를 사용

나쁜 예: 전역 헤더를 덮어쓰는 패턴

# anti-pattern
HEADERS = {}

def call_openai(api_key: str, payload: dict):
    global HEADERS
    HEADERS["Authorization"] = f"Bearer {api_key}"  # 다른 요청이 덮어쓸 수 있음
    return httpx.post("https://api.openai.com/v1/responses", headers=HEADERS, json=payload)

좋은 예: 요청 스코프에서 헤더를 생성

import httpx

client = httpx.Client(timeout=30)

def call_openai(api_key: str, payload: dict):
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }
    return client.post("https://api.openai.com/v1/responses", headers=headers, json=payload)

점검 포인트

  • 401이 특정 워커/파드에서만 나는지(로그에 pod_name, worker_id 찍기)
  • 요청별로 사용된 키의 마스킹 값을 함께 로깅해서 키 혼입 여부 확인

빠른 결론: 10분 내 원인 좁히는 체크리스트

운영에서 바로 적용할 수 있게 “최단 경로”로 정리하면 아래 순서가 효율적입니다.

  1. 요청이 실제로 api.openai.com/v1/responses로 가는지(BASE_URL/프록시 확인)
  2. OpenAI에 도착하는 Authorization 헤더가 Bearer로 존재하는지
  3. 런타임에서 실제 주입된 키가 무엇인지(마스킹 로그)
  4. 키가 속한 조직/프로젝트/모델 권한이 맞는지(RBAC/스코프)
  5. 프록시/WAF가 Authorization을 제거/변조하지 않는지
  6. 멀티테넌트/멀티워커에서 키가 섞이는 레이스가 없는지
  7. 계정/조직이 달라서 생기는 정책 차이가 없는지

401을 “키 문자열 문제”로만 보면 디버깅이 길어집니다. 반대로 위 7가지를 순서대로 보면 대부분은 헤더 전달/프로젝트 권한/런타임 시크릿 중 하나에서 바로 잡힙니다.


부록: 최소 동작 확인용 Responses API 샘플

문제 재현/검증 시에는 가능한 한 단순한 요청으로 시작하세요.

export OPENAI_API_KEY="your_key"

curl -sS https://api.openai.com/v1/responses \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4.1-mini","input":"Say OK"}' | jq

여기서 성공하면, 다음 단계로 프록시 경유, 서비스 코드 경유, 스트리밍(SSE) 순으로 복잡도를 올리며 어디에서 401이 발생하는지 경계(boundary)를 잡는 방식이 가장 빠릅니다.