API 키를 코드에 넣지 않는 이유와 .env 관리
Notion·메모에 키를 모아 두다 생긴 실수와, Git·배포 환경에서 비밀값을 나누는 현실적인 방법을 정리합니다.

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가 켜진 스크립트는 인자·환경이 그대로 찍힐 수 있습니다.
실패했던 운영 습관
- 노션 한 페이지에 모든 키 — 검색·공유·모바일 스크린샷에 취약합니다. 지금은 서비스별 패스워드 매니저 항목 + “어디에 쓰는지”만 노션에 적습니다.
- 채팅에 키 붙여 넣고 ‘나중에 지울게’ — 로그·알림 아카이브에 남습니다. 처음부터 안 붙입니다.
- 만료·로테이션 없음 — 무료 키가 여러 개면 어느 프로젝트가 살아 있는지 모릅니다. 표에는 이름·용도·마지막 교체일만 두고 값은 매니저에 둡니다.
유출됐을 때
- 해당 서비스에서 키 폐기·재발급
- Git에 들어갔다면 히스토리 정리(단순 커밋 삭제로 부족할 수 있음)
- 같은 키가 붙은 봇·Webhook·Workers 재설정
- 노션/슬랙/이슈에 붙은 평문 삭제
“커밋만 revert”로는 원격에 받은 사람·미러·CI 캐시에 남을 수 있습니다. 히스토리 rewrite가 필요하면 그 뒤에 반드시 재발급합니다. 옛 키는 죽은 것으로 취급합니다.
OpenRouter·Groq처럼 무료 키가 여러 개면 “어디에 어떤 키가 붙었는지” 표로만 관리하고, 값 자체는 패스워드 매니저에 둡니다. 2FA와 매니저 습관은 비밀번호 관리·2FA에, Git에 올리지 않는 감각은 커밋·푸시 습관에 맞춰 두면 덜 헷갈립니다. 모델 비용 비교는 OpenRouter로 코딩할 때 모델 비용에 숫자 감각만 적어 두었습니다.
새 키를 붙일 때 체크
서비스를 하나 추가할 때마다 아래만 통과시키면, “어디에 평문이 남았는지”를 나중에 추적하기 쉽습니다.
- 값은 패스워드 매니저(또는 CI secret)에만 저장한다.
.env.example에는 이름만 추가하고, 실값은 로컬.env에만 둔다.git status/git diff로 추적 파일이 아닌 것을 확인한다.- 스크립트·워크플로가 키를 stdout에 찍지 않는지 한 번 돌린다.
- 블로그·이슈·노션 예시에는
sk-...처럼 마스킹만 쓴다.
키 관리는 거창한 보안 체계보다, 평문을 한곳에 몰아두지 않는 습관이 먼저입니다. 새 서비스를 붙일 때마다 “이 값이 Git·노션·스크린샷·에이전트 로그 중 어디에 남는가?”만 물어봐도 사고가 많이 줄어듭니다. 블로그에 키를 예시로 쓸 때는 처음부터 마스킹된 자리표시만 넣습니다. 실수로라도 실키가 들어갔다면 글을 고치기 전에 키부터 폐기합니다. 배포 토큰이 바뀌면 CI·로컬 wrangler·대시보드 Secrets를 같은 날 맞추고, 옛 토큰은 콘솔에서 바로 폐기합니다. 같은 키를 여러 머신·CI에 복사해 두지 말고, 필요한 환경에만 넣습니다.