IT 시행착오··약 8분

API 키를 코드에 넣지 않는 이유와 .env 관리

Notion·메모에 키를 모아 두다 생긴 실수와, Git·배포 환경에서 비밀값을 나누는 현실적인 방법을 정리합니다.

사이버보안·시크릿 관리 — Cybersecurity.png (cc0, wikimedia-commons)

API 키는 “프로그램용 비밀번호”입니다. AI, 지도, 결제, 클라우드, 메신저 봇까지 키가 있으면 그 권한으로 호출할 수 있습니다. 그래서 저장소에 평문으로 올리는 건 비밀번호를 이슈에 붙여 넣는 것과 비슷합니다.

저도 편하려고 Notion “API 키” 페이지에 Cloudflare·OpenRouter·Groq·텔레그램 봇 토큰을 한꺼번에 적어 둔 적이 있습니다. 작업할 때는 편한데, 그 페이지가 공유되거나 스크린샷·에이전트 로그에 섞이면 유출 면적이 커집니다. 블로그 글이나 공개 레포에는 절대 실키를 붙이지 않는 것을 원칙으로 바꿨습니다. 예시는 항상 sk-... / cfut_...처럼 마스킹합니다.

코드와 비밀을 나누는 이유

소스는 공유·리뷰·히스토리가 전제입니다. 키는 환경마다 다르고, 유출 시 바로 폐기·재발급할 수 있어야 합니다. 코드에 박아 두면:

  • Git 이력에 영원히 남는다
  • PR·CI 로그에 찍힐 수 있다
  • 개인 저장소가 공개로 바뀌는 순간 사고가 된다
  • 개발키와 운영키가 섞여 요금이 나온다

환경 변수로 빼면 코드는 그대로 두고 로컬/프리뷰/프로덕션 값만 바꿉니다.

실제로 아프게 배운 포인트

에이전트·터미널 로그에 export 한 줄이 그대로 남는 경우가 있습니다. “로컬만”이라고 생각한 값이 채팅 첨부·이슈 본문에 들어가면 공개와 같습니다. 그래서 디버깅할 때 echo $OPENROUTER_API_KEY 같은 명령을 습관적으로 치지 않습니다. 필요하면 길이·앞 네 글자만 확인합니다.

# 값은 출력하지 않고 존재·길이만
python3 -c 'import os; v=os.environ.get("OPENROUTER_API_KEY",""); print(len(v), (v[:4]+"...") if v else "missing")'

커밋 전에 git diff로 실키가 섞였는지 봅니다. .env를 추적에 넣었다가 바로 unstage 한 적도 있습니다. pre-commit에 시크릿 스캐너가 있으면 그 경고를 무시하지 않습니다.

로컬은 .env, Git에는 안 넣기

프로젝트 루트에 .env를 두고:

OPENROUTER_API_KEY=여기에_로컬용만
CLOUDFLARE_ACCOUNT_ID=계정ID는_공개돼도_되는_경우만

.gitignore.env, .env.*, !.env.example을 넣습니다. .env.example에는 키 이름만 남깁니다.

OPENROUTER_API_KEY=
CLOUDFLARE_API_TOKEN=

Astro/Vite처럼 클라이언트에 노출되는 접두사(PUBLIC_ 등)는 공개 값만 넣습니다. AdSense 클라이언트 ID처럼 원래 공개되는 값과, Workers 배포 토큰은 등급이 다릅니다.

실수로 PUBLIC_에 배포 토큰을 넣은 채 빌드하면, 번들에 키가 박힙니다. “프론트에 보이게 하려고”가 아니라 “이름만 맞추다” 생기는 사고입니다. 공개 접두사가 필요한지부터 묻습니다.

.env.local / .env.production을 나눠 쓰는 팀도 있습니다. 저는 개인 블로그·자동화에서는 .env(로컬)와 CI secrets(원격) 이원화만으로도 충분했습니다. 파일을 늘릴수록 “어느 파일이 진짜인가”가 흔들립니다.

배포 쪽

Cloudflare면 대시보드 Secrets / Environment variables, GitHub Actions면 Repository secrets에 넣습니다. 노션 배포 메모에 토큰 전체를 붙여 두고 wrangler deploy만 복사해 쓰던 방식은, 메모 유출 = 계정 유출입니다. 스크립트는 키를 읽기만 하고 출력하지 않게 합니다.

set -a; source .secrets/xxx.env; set +a처럼 로컬에서만 불러오는 패턴은 편하지만, .secrets/도 Git 제외가 필수입니다.

Workers 배포 토큰과 Zone Bot Management 토큰을 하나로 합치지 않는 편이 낫습니다. 권한이 넓은 토큰 하나가 유출되면, 배포뿐 아니라 DNS·보안 설정까지 바뀝니다. 블로그에서 Bot Fight를 끄고 켠 경험(Bot Fight 글 맥락)도 “배포만 되는 토큰”으로는 못 했습니다. 권한은 작업 단위로 나눕니다.

GitHub Actions에서 wrangler deploy를 돌릴 때는 Repository secrets에만 넣고, 워크플로 로그에 env: 전체를 dump하지 않습니다. set -x가 켜진 스크립트는 인자·환경이 그대로 찍힐 수 있습니다.

실패했던 운영 습관

  1. 노션 한 페이지에 모든 키 — 검색·공유·모바일 스크린샷에 취약합니다. 지금은 서비스별 패스워드 매니저 항목 + “어디에 쓰는지”만 노션에 적습니다.
  2. 채팅에 키 붙여 넣고 ‘나중에 지울게’ — 로그·알림 아카이브에 남습니다. 처음부터 안 붙입니다.
  3. 만료·로테이션 없음 — 무료 키가 여러 개면 어느 프로젝트가 살아 있는지 모릅니다. 표에는 이름·용도·마지막 교체일만 두고 값은 매니저에 둡니다.

유출됐을 때

  1. 해당 서비스에서 키 폐기·재발급
  2. Git에 들어갔다면 히스토리 정리(단순 커밋 삭제로 부족할 수 있음)
  3. 같은 키가 붙은 봇·Webhook·Workers 재설정
  4. 노션/슬랙/이슈에 붙은 평문 삭제

“커밋만 revert”로는 원격에 받은 사람·미러·CI 캐시에 남을 수 있습니다. 히스토리 rewrite가 필요하면 그 뒤에 반드시 재발급합니다. 옛 키는 죽은 것으로 취급합니다.

OpenRouter·Groq처럼 무료 키가 여러 개면 “어디에 어떤 키가 붙었는지” 표로만 관리하고, 값 자체는 패스워드 매니저에 둡니다. 2FA와 매니저 습관은 비밀번호 관리·2FA에, Git에 올리지 않는 감각은 커밋·푸시 습관에 맞춰 두면 덜 헷갈립니다. 모델 비용 비교는 OpenRouter로 코딩할 때 모델 비용에 숫자 감각만 적어 두었습니다.

새 키를 붙일 때 체크

서비스를 하나 추가할 때마다 아래만 통과시키면, “어디에 평문이 남았는지”를 나중에 추적하기 쉽습니다.

  1. 값은 패스워드 매니저(또는 CI secret)에만 저장한다.
  2. .env.example에는 이름만 추가하고, 실값은 로컬 .env에만 둔다.
  3. git status / git diff로 추적 파일이 아닌 것을 확인한다.
  4. 스크립트·워크플로가 키를 stdout에 찍지 않는지 한 번 돌린다.
  5. 블로그·이슈·노션 예시에는 sk-...처럼 마스킹만 쓴다.

키 관리는 거창한 보안 체계보다, 평문을 한곳에 몰아두지 않는 습관이 먼저입니다. 새 서비스를 붙일 때마다 “이 값이 Git·노션·스크린샷·에이전트 로그 중 어디에 남는가?”만 물어봐도 사고가 많이 줄어듭니다. 블로그에 키를 예시로 쓸 때는 처음부터 마스킹된 자리표시만 넣습니다. 실수로라도 실키가 들어갔다면 글을 고치기 전에 키부터 폐기합니다. 배포 토큰이 바뀌면 CI·로컬 wrangler·대시보드 Secrets를 같은 날 맞추고, 옛 토큰은 콘솔에서 바로 폐기합니다. 같은 키를 여러 머신·CI에 복사해 두지 말고, 필요한 환경에만 넣습니다.