Skip to content

Split View: 시크릿 관리, .env 파일만으로는 부족한 이유 — 환경변수가 새는 경로와 로테이션 설계

✨ Learn with Quiz
|

시크릿 관리, .env 파일만으로는 부족한 이유 — 환경변수가 새는 경로와 로테이션 설계

들어가며 — .env를 .gitignore에 넣었으니 안전한가

시크릿 관리에 대한 표준적인 조언은 짧습니다. 코드에 하드코딩하지 말고 환경변수로 빼라, .env 파일은 .gitignore에 넣어라. 여기까지가 대부분의 문서가 다루는 범위입니다.

문제는 이 조언이 딱 한 가지 위협만 막는다는 것입니다. 소스 코드 저장소에 평문으로 들어가는 것. 그런데 실제 사고 보고서를 읽어 보면 유출 경로는 훨씬 다양합니다. 프로세스 메모리, 크래시 리포트, CI 로그, 컨테이너 이미지 레이어, APM 대시보드, 그리고 잘못 노출된 디버그 엔드포인트입니다.

더 중요한 문제는 그다음입니다. 시크릿이 새었다는 것을 알았을 때, 몇 분 안에 그 키를 무효화하고 새 키로 교체할 수 있습니까? 대부분의 팀에서 이 질문의 답은 "모른다"입니다. 어디에 몇 벌이 복사되어 있는지 모르고, 교체하면 무엇이 죽는지도 모르기 때문입니다.

이 글은 두 가지를 다룹니다. 환경변수가 실제로 어디까지 새는지, 그리고 새어도 5분 안에 대응할 수 있는 구조를 어떻게 만드는지입니다.

환경변수가 실제로 새어 나가는 경로

환경변수는 프로세스 속성입니다. 파일이 아니라 커널이 프로세스마다 들고 있는 문자열 배열이고, 리눅스에서는 그것을 파일처럼 읽을 수 있습니다.

ps -eo pid,user,comm | grep -E 'node|python'
   2841 app      node
   3102 app      python3
# 같은 UID 또는 root라면 프로세스의 환경변수를 통째로 읽는다
tr '\0' '\n' < /proc/2841/environ | grep -iE 'key|token|secret|password'
DATABASE_URL=postgres://app:pr0d-Db-Pass@db.internal:5432/app
STRIPE_SECRET_KEY=sk_live_51NxbQ2Lk9vHc0pMz7RtYaWq
JWT_SIGNING_KEY=8f2a1c9d4e7b6a305f18c2d9e0b7a4f1

여기서 중요한 사실은 이 파일이 프로세스가 살아 있는 동안 계속 읽힌다는 것입니다. .env 파일을 로드한 뒤 삭제해도 소용이 없습니다. 값은 이미 커널에 있습니다.

컨테이너 안이라고 다르지 않습니다. 같은 파드의 사이드카, 노드에 접근 가능한 사람, 그리고 디버그 컨테이너가 모두 같은 것을 봅니다.

kubectl debug -it deploy/payments --image=busybox --target=app -- sh
# 디버그 컨테이너에서
tr '\0' '\n' < /proc/1/environ

두 번째 경로는 자식 프로세스입니다. 환경변수는 상속됩니다. 애플리케이션이 이미지 변환기나 백업 스크립트 같은 외부 도구를 실행하면, 그 도구가 자기 로그에 환경 전체를 덤프하는 순간 시크릿이 로그 수집기로 흘러 들어갑니다.

세 번째는 크래시 리포트와 디버그 페이지입니다. 다음은 실제로 자주 보이는 안티패턴입니다.

// 절대 하면 안 되는 코드
process.on('uncaughtException', (err) => {
  logger.error({ err, env: process.env }, 'fatal error, dumping context')
  process.exit(1)
})

이 한 줄로 모든 시크릿이 로그 인덱스에 영구 저장됩니다. 로그 수집 시스템은 대개 애플리케이션보다 접근 권한이 넓습니다. 프레임워크의 디버그 페이지도 같은 위험을 갖습니다. Django의 디버그 모드는 예외 페이지에 설정 값을 렌더링하고, Werkzeug 디버거는 대화형 콘솔까지 열어 줍니다. 스테이징에서 켜 둔 채로 인터넷에 노출된 사례가 반복해서 나옵니다.

네 번째는 CI 로그입니다. 시크릿 마스킹 기능이 있어도 변형되면 뚫립니다.

# 마스킹은 정확히 일치하는 문자열만 가린다
set -x
curl -H "Authorization: Bearer $API_TOKEN" https://api.example.com/deploy
echo "$API_TOKEN" | base64        # base64로 인코딩되면 마스킹을 통과한다
+ curl -H 'Authorization: Bearer ***' https://api.example.com/deploy
+ base64
c2tfbGl2ZV81MU54YlEyTGs5dkhjMHBNejdSdFlhV3E=

마지막은 컨테이너 이미지입니다. 빌드 인자로 넘긴 값은 이미지 히스토리에 그대로 남습니다.

docker build --build-arg NPM_TOKEN=npm_9f3aQ2v8Lx -t myapp:1.4.2 .
docker history --no-trunc myapp:1.4.2 | grep -o 'NPM_TOKEN=[A-Za-z0-9_]*'
NPM_TOKEN=npm_9f3aQ2v8Lx

RUN 단계에서 파일을 지워도 이전 레이어에 남습니다. 레지스트리에 푸시했다면 그 이미지를 받은 모든 사람이 읽을 수 있습니다. 빌드 시점 시크릿은 반드시 마운트 방식으로 넘겨야 합니다.

# syntax=docker/dockerfile:1
FROM node:22-slim
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --omit=dev
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp:1.4.2 .

시크릿이 이미 커밋됐다면 — 폐기가 1순위, 히스토리는 2순위

깃 히스토리에서 키를 발견했을 때 가장 흔한 반응은 히스토리 재작성입니다. 이것은 순서가 틀렸습니다. 올바른 순서는 폐기, 영향 조사, 히스토리 정리입니다.

이유는 단순합니다. 공개 저장소에 푸시된 자격증명은 수 초에서 수 분 안에 자동화된 스캐너에 수집됩니다. 히스토리를 아무리 깨끗하게 지워도 이미 복사된 값은 되돌릴 수 없습니다. 그리고 히스토리 재작성만으로는 다음이 남습니다.

포크된 저장소, 이미 클론한 개발자들의 로컬 사본, 플랫폼이 보관하는 풀 리퀘스트 참조(커밋 해시를 알면 여전히 접근 가능한 경우가 많습니다), 검색 엔진과 코드 검색 서비스의 캐시, CI 캐시와 아티팩트입니다. 즉 히스토리 재작성은 유출을 되돌리는 조치가 아니라 재발을 줄이는 위생 작업입니다.

1순위는 무효화입니다.

# 새 키를 먼저 만들고 배포한 뒤, 구 키를 비활성화하고 삭제한다
aws iam create-access-key --user-name ci-deployer
aws iam update-access-key --access-key-id AKIAIOSFODNN7EXAMPLE \
  --status Inactive --user-name ci-deployer
aws iam delete-access-key --access-key-id AKIAIOSFODNN7EXAMPLE --user-name ci-deployer

2순위는 영향 조사입니다. 그 키가 언제부터 어디서 쓰였는지 확인합니다.

aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=AccessKeyId,AttributeValue=AKIAIOSFODNN7EXAMPLE \
  --start-time 2026-07-01T00:00:00Z \
  --query 'Events[].[EventTime,EventName,Username,CloudTrailEvent]' --output text | head -20

낯선 IP, 평소 쓰지 않던 리전, 예상 밖의 API 호출이 보이면 침해 대응으로 전환해야 합니다.

3순위가 히스토리 정리입니다. 먼저 어느 커밋에 들어갔는지 찾습니다.

git log --all --oneline -S 'AKIAIOSFODNN7EXAMPLE'
7c19ae4 chore: add deploy script
2f80b13 fix: correct region in deploy script

정리는 git filter-repo가 표준입니다. filter-branch는 느리고 실수하기 쉬워 더 이상 권장되지 않습니다.

cat > replacements.txt <<'EOF'
AKIAIOSFODNN7EXAMPLE==>REDACTED
pr0d-Db-Pass==>REDACTED
EOF

git filter-repo --replace-text replacements.txt --force
git push --force-with-lease --all
git push --force-with-lease --tags

이후 모든 협업자에게 새로 클론하도록 공지해야 합니다. 기존 클론에서 그대로 푸시하면 지운 커밋이 되살아납니다.

저장 방식 비교 — 그리고 쿠버네티스 Secret의 base64는 암호화가 아닙니다

시크릿 관리 도구를 고르는 기준은 "암호화되는가"가 아닙니다. 노출 경로가 몇 개인가, 폐기와 교체가 몇 분 걸리는가, 누가 언제 읽었는지 알 수 있는가입니다.

저장 방식실제 노출 경로로테이션 비용접근 감사
소스에 하드코딩저장소, 포크, 클론, 코드 검색 캐시재배포 필요, 사실상 불가없음
.env 파일 + 환경변수프로세스 환경, 자식 프로세스, 크래시 리포트, 로그수동 배포, 누락 발생없음
이미지에 굽기(ENV, ARG)위 전부 + 이미지 레이어, 레지스트리 접근자 전원이미지 재빌드와 전체 롤아웃없음
CI 변수빌드 로그, 포크 PR 워크플로, 아티팩트콘솔에서 교체, 소비처 추적 난망제한적
쿠버네티스 Secret 기본etcd 평문, get secrets 권한자 전원, 파드 환경재시작 필요API 감사 로그
쿠버네티스 Secret + KMS 봉투get secrets 권한자, 파드 환경재시작 필요API 감사와 KMS
시크릿 매니저 정적 값애플리케이션 메모리, 캐시 TTL 동안콘솔 또는 API 호출 한 번읽기 단위 기록
시크릿 매니저 동적 자격증명짧은 수명 자격증명만, 리스 만료 후 자동 무효자동, 인간 개입 없음리스 단위 기록
워크로드 아이덴티티(OIDC)저장된 정적 키 없음, 토큰만 수명 동안로테이션 대상 자체가 없음토큰 발급 기록

아래로 갈수록 좋은 것이 아니라, 아래로 갈수록 사고 시 대응 시간이 짧아집니다. 이 표에서 실무자가 가장 자주 오해하는 줄이 쿠버네티스 Secret이므로 그것부터 짚습니다.

Secret 매니페스트를 보면 값이 base64로 인코딩되어 있어 암호화된 것처럼 보입니다. base64는 인코딩이지 암호화가 아닙니다. 키가 없고, 되돌리는 데 명령 하나면 됩니다.

kubectl get secret app-db -o jsonpath='{.data.password}' | base64 -d; echo
pr0d-Db-Pass

기본 설정에서 Secret은 etcd에 평문으로 저장됩니다. etcd 백업 파일, 스냅숏, 디스크 이미지를 얻은 사람은 모든 Secret을 읽습니다. 확인해 봅니다.

ETCDCTL_API=3 etcdctl \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key \
  get /registry/secrets/default/app-db | hexdump -C | head -4
00000000  2f 72 65 67 69 73 74 72  79 2f 73 65 63 72 65 74  |/registry/secret|
00000010  73 2f 64 65 66 61 75 6c  74 2f 61 70 70 2d 64 62  |s/default/app-db|
00000020  0a 6b 38 73 00 0a 0c 0a  02 76 31 12 06 53 65 63  |.k8s.....v1..Sec|
00000030  72 65 74 12 8e 01 0a 6f  0a 06 61 70 70 2d 64 62  |ret....o..app-db|

평문이 그대로 보입니다. 저장 시 암호화를 켜면 값 앞에 프로바이더 접두사가 붙고 본문은 읽을 수 없게 됩니다.

apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets
    providers:
      - kms:
          apiVersion: v2
          name: cloud-kms
          endpoint: unix:///var/run/kmsplugin/socket.sock
      - identity: {}
00000000  2f 72 65 67 69 73 74 72  79 2f 73 65 63 72 65 74  |/registry/secret|
00000020  0a 6b 38 73 3a 65 6e 63  3a 6b 6d 73 3a 76 32 3a  |.k8s:enc:kms:v2:|

identity는 반드시 목록의 마지막에 두어야 합니다. 앞에 오면 평문 저장으로 되돌아갑니다. 설정을 바꿔도 기존 Secret은 다시 쓰기 전까지 평문으로 남으므로 전체를 한 번 갱신해야 합니다.

kubectl get secrets --all-namespaces -o json | kubectl replace -f -

두 번째 오해는 RBAC입니다. 네임스페이스에 get secrets 권한이 있는 사람은 그 네임스페이스의 모든 Secret을 읽습니다. 개발자에게 편의로 준 편집 권한이 사실상 프로덕션 자격증명 열람 권한인 경우가 매우 흔합니다.

kubectl auth can-i get secrets --namespace payments --as dev@example.com
yes

실무에서 자주 쓰이는 절충안이 외부 시크릿 오퍼레이터입니다. 실제 값은 시크릿 매니저에 두고, 클러스터에는 참조만 커밋합니다.

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: app-db
  namespace: payments
spec:
  refreshInterval: 15m
  secretStoreRef:
    name: aws-secretsmanager
    kind: ClusterSecretStore
  target:
    name: app-db
  data:
    - secretKey: password
      remoteRef:
        key: prod/payments/db
        property: password

이 매니페스트는 깃에 커밋해도 됩니다. 값이 없기 때문입니다. 다만 오퍼레이터가 값을 클러스터 Secret으로 실체화하므로 앞의 RBAC와 저장 시 암호화 문제는 그대로 남습니다. 시크릿 매니저를 쓴다고 클러스터 쪽 위생이 면제되지는 않습니다.

정적 키를 없애는 구조 — 짧은 수명 자격증명과 OIDC 페더레이션

지금까지의 논의는 모두 "정적 키를 어디에 둘 것인가"였습니다. 더 나은 답은 정적 키를 만들지 않는 것입니다.

CI에서 클라우드에 접근할 때 예전 방식은 액세스 키를 만들어 CI 변수에 넣는 것이었습니다. 이 키는 만료되지 않고, 누가 복사했는지 알 수 없으며, 로테이션은 사람이 기억해야 합니다. 지금은 러너가 발급받은 OIDC 토큰을 클라우드 자격증명으로 교환합니다.

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
          aws-region: ap-northeast-2
      - run: aws sts get-caller-identity
{
    "UserId": "AROAY3EXAMPLEID:GitHubActions",
    "Account": "123456789012",
    "Arn": "arn:aws:sts::123456789012:assumed-role/gha-deploy/GitHubActions"
}

저장된 키가 없습니다. 자격증명은 잡 실행 시간 동안만 유효합니다.

여기서 가장 자주 나오는 설정 실수가 신뢰 정책의 조건절입니다. 다음은 위험한 설정입니다.

{
  "Condition": {
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:acme/*"
    }
  }
}

이 조건은 조직의 어떤 저장소, 어떤 브랜치, 어떤 풀 리퀘스트에서도 이 역할을 가져갈 수 있게 합니다. 포크에서 올라온 PR 워크플로가 프로덕션 배포 역할을 얻는 경로가 생깁니다. 브랜치나 환경까지 못박아야 합니다.

{
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
      "token.actions.githubusercontent.com:sub": "repo:acme/payments:environment:production"
    }
  }
}

쿠버네티스 파드도 같은 구조를 씁니다. 프로젝티드 서비스 어카운트 토큰은 대상과 만료 시간을 지정할 수 있고, 클라우드 STS가 그것을 검증합니다.

spec:
  serviceAccountName: payments
  volumes:
    - name: aws-token
      projected:
        sources:
          - serviceAccountToken:
              audience: sts.amazonaws.com
              expirationSeconds: 3600
              path: token

데이터베이스 자격증명도 동적으로 만들 수 있습니다. Vault의 데이터베이스 시크릿 엔진은 요청 시점에 사용자를 생성하고 리스가 끝나면 삭제합니다.

vault read database/creds/payments-readonly
Key                Value
---                -----
lease_id           database/creds/payments-readonly/9tKq2xL0
lease_duration     1h
lease_renewable    true
password           A1a-3mQx9Zr7Kt2Vb0Ns
username           v-token-payments-readonly-8Xk1qP

이 자격증명은 한 시간 뒤 스스로 사라집니다. 유출되어도 창이 좁고, 로그에 사용자 이름이 남아 어느 요청에서 발급된 것인지 추적됩니다. 정적 키를 완벽하게 숨기려 애쓰는 것보다 수명을 한 시간으로 줄이는 편이 거의 항상 더 효과적입니다.

로테이션을 가능하게 만드는 설계 — 두 개의 유효 키

로테이션이 실패하는 이유는 대개 저장소 문제가 아닙니다. 코드가 "유효한 키는 정확히 하나"라고 가정하기 때문입니다. 이 가정 아래에서 키 교체는 반드시 순간적인 불일치 구간을 만들고, 그래서 아무도 로테이션을 하지 않게 됩니다.

해결책은 검증과 서명을 분리하는 것입니다. 서명은 항상 현재 키 하나로 하고, 검증은 유효 키 집합 전체에 대해 수행합니다.

웹훅 서명 검증을 예로 들면 취약한 구현은 이렇습니다.

import hmac, hashlib, os

SECRET = os.environ["WEBHOOK_SECRET"]

def verify(body: bytes, signature: str) -> bool:
    expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

키를 바꾸는 순간 이전 키로 서명된 요청이 전부 거부됩니다. 전송 측과 수신 측을 원자적으로 동시에 바꿀 방법이 없으므로 사실상 교체가 불가능합니다.

집합으로 바꾸면 교체가 가능해집니다.

import hmac, hashlib, os

# 콤마로 구분된 유효 키 목록. 첫 번째가 현재 서명 키.
KEYS = [k.strip() for k in os.environ["WEBHOOK_SECRETS"].split(",") if k.strip()]

def sign(body: bytes) -> str:
    return hmac.new(KEYS[0].encode(), body, hashlib.sha256).hexdigest()

def verify(body: bytes, signature: str) -> bool:
    for key in KEYS:
        expected = hmac.new(key.encode(), body, hashlib.sha256).hexdigest()
        if hmac.compare_digest(expected, signature):
            return True
    return False

이제 교체 절차가 무중단으로 성립합니다.

1단계  신 키를 유효 목록 끝에 추가하고 배포한다   → 구 키로 서명, 둘 다 검증
2단계  신 키를 목록 맨 앞으로 옮기고 배포한다      → 신 키로 서명, 둘 다 검증
3단계  구 키로 서명된 요청이 사라졌는지 확인한다   → 지표로 확인
4단계  구 키를 목록에서 제거하고 배포한다          → 신 키만 유효
5단계  구 키를 발급처에서 폐기한다

3단계를 눈으로 확인하려면 어떤 키가 매칭됐는지 지표로 남겨야 합니다.

def verify(body: bytes, signature: str) -> bool:
    for index, key in enumerate(KEYS):
        expected = hmac.new(key.encode(), body, hashlib.sha256).hexdigest()
        if hmac.compare_digest(expected, signature):
            metrics.increment("webhook.signature.verified", tags=[f"key_index:{index}"])
            return True
    metrics.increment("webhook.signature.failed")
    return False

key_index:1의 카운터가 0이 되면 구 키를 지워도 안전하다는 뜻입니다. 로테이션을 추측이 아니라 관측으로 진행할 수 있게 됩니다.

JWT라면 같은 원리가 kid 헤더와 JWKS로 구현됩니다. 발급자는 JWKS에 신 키를 먼저 공개하고, 검증자가 그것을 가져간 뒤에 서명 키를 바꿉니다. 데이터베이스 계정은 사용자 두 개를 번갈아 쓰는 방식이 표준입니다. 짝수 회차에는 app_a, 홀수 회차에는 app_b를 갱신하고, 애플리케이션은 시크릿 매니저에서 현재 활성 사용자를 읽습니다. 어느 순간에도 유효한 자격증명이 하나 이상 존재하므로 중단이 없습니다.

핵심은 이것입니다. 로테이션은 운영 절차가 아니라 설계 속성입니다. 코드가 키 하나만 가정하면 아무리 좋은 시크릿 매니저를 써도 실제 교체는 일어나지 않습니다.

탐지 — 사전 커밋 훅과 저장소 스캐닝이 못 하는 일

마지막 층이 탐지입니다. 도구는 성숙해 있습니다.

# 작업 트리와 히스토리 전체 스캔
gitleaks detect --source . --redact --report-format json --report-path leaks.json

# 커밋 직전 스테이지된 변경만 검사
gitleaks protect --staged --redact
Finding:     STRIPE_SECRET_KEY=sk_live_REDACTED
Secret:      REDACTED
RuleID:      stripe-access-token
File:        deploy/staging.env
Line:        14
Commit:      7c19ae4a2f3b18c0d95e7f4a6b2c1d80e3f9a5b6

사전 커밋 훅으로 걸어 두면 개발자 로컬에서 잡힙니다.

repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.4
    hooks:
      - id: gitleaks

검증 기능이 있는 스캐너는 노이즈를 크게 줄여 줍니다. 발견한 값으로 실제 API를 호출해 살아 있는 키만 보고하는 방식입니다.

trufflehog git file://. --only-verified --json | jq -r '.DetectorName + " " + .Verified'

여기까지가 도구가 해 주는 일이고, 다음이 한계입니다.

사전 커밋 훅은 로컬 설정이라 우회됩니다. git commit --no-verify 한 번이면 끝이고, 새로 합류한 사람은 훅을 설치하지 않은 상태로 며칠을 보냅니다. 실제 강제력은 서버 쪽 푸시 보호에서 나옵니다. 훅은 편의 장치, 서버 검사는 통제 장치로 구분해야 합니다.

탐지 규칙은 형태가 뚜렷한 값에만 잘 듣습니다. AKIA로 시작하는 액세스 키나 sk_live_ 접두사가 붙은 토큰은 정확히 잡히지만, 데이터베이스 비밀번호나 사설 서비스의 API 키처럼 형태가 없는 값은 엔트로피 추정에 의존합니다. 엔트로피 규칙은 오탐이 많아 임계값을 올리게 되고, 그러면 진짜 비밀번호를 놓칩니다.

그리고 스캐너는 코드만 봅니다. 위키, 이슈 첨부파일, 채팅 기록, 스프레드시트, 로컬 노트에 복사된 사본은 대상이 아닙니다. 실제 사고에서 유출 지점이 저장소가 아닌 경우가 상당수입니다.

그래서 탐지에 투자하기 전에 확인할 것이 하나 있습니다. 이 시크릿이 노출됐다고 가정할 때 폐기와 교체에 몇 분이 걸리는가. 이 숫자가 크면 탐지를 아무리 촘촘히 해도 사고 시 손실은 줄지 않습니다. 반대로 이 숫자가 작으면 탐지가 늦어도 피해가 제한됩니다. 우선순위는 언제나 대응 시간 단축이 먼저입니다.

마치며 — 숨겼는가가 아니라 몇 분에 바꿀 수 있는가

정리하면 세 문장입니다.

환경변수는 저장 위치가 아니라 전달 방식입니다. 프로세스 환경은 같은 호스트에서 읽히고, 로그와 크래시 리포트와 이미지 레이어로 계속 새어 나갑니다. .gitignore는 그중 한 경로만 막습니다.

시크릿이 노출됐을 때의 순서는 폐기, 조사, 히스토리 정리입니다. 반대로 하면 이미 복제된 값을 그대로 둔 채 정리 작업에 시간을 쓰게 됩니다.

그리고 가장 확실한 개선은 정적 키를 줄이는 것입니다. CI는 OIDC 페더레이션으로, 데이터베이스는 동적 자격증명으로, 남는 정적 키는 유효 키 집합 설계로 언제든 교체 가능하게 만듭니다.

팀의 시크릿 관리 성숙도를 측정하는 질문은 하나면 충분합니다. 지금 이 자격증명이 인터넷에 공개됐다고 가정할 때, 폐기하고 새 값으로 교체해 서비스를 정상화하는 데 몇 분이 걸립니까. 그 답이 시간 단위라면 도구를 더 사는 것보다 로테이션 경로를 먼저 만들어야 합니다.

Secrets Management — Why a .env File Is Not Enough, the Paths Environment Variables Leak Through, and How to Design Rotation

Introduction — .env Is in .gitignore, So Is It Safe?

The standard advice about secrets management is short. Do not hardcode them, move them into environment variables, and put the .env file in .gitignore. That is where most documents stop.

The problem is that this advice blocks exactly one threat: plaintext landing in the source code repository. Read actual incident reports, though, and the leak paths are far more varied. Process memory, crash reports, CI logs, container image layers, APM dashboards, and misconfigured debug endpoints.

The more important problem comes next. When you learn a secret has leaked, can you invalidate that key and swap in a new one within minutes? In most teams, the answer to this question is "we do not know." Nobody knows how many copies are sitting where, and nobody knows what breaks when the value changes.

This post covers two things: how far environment variables actually leak, and how to build a structure that lets you respond within five minutes when they do.

The Paths Environment Variables Actually Leak Through

An environment variable is a process attribute. It is not a file — it is a string array the kernel holds per process, and on Linux you can read it like a file.

ps -eo pid,user,comm | grep -E 'node|python'
   2841 app      node
   3102 app      python3
# With the same UID or as root, read the whole process environment
tr '\0' '\n' < /proc/2841/environ | grep -iE 'key|token|secret|password'
DATABASE_URL=postgres://app:pr0d-Db-Pass@db.internal:5432/app
STRIPE_SECRET_KEY=sk_live_51NxbQ2Lk9vHc0pMz7RtYaWq
JWT_SIGNING_KEY=8f2a1c9d4e7b6a305f18c2d9e0b7a4f1

The important fact here is that this file stays readable for as long as the process is alive. Deleting the .env file after loading it changes nothing. The values are already in the kernel.

Inside a container it is no different. A sidecar in the same pod, anyone with node access, and a debug container all see the same thing.

kubectl debug -it deploy/payments --image=busybox --target=app -- sh
# From inside the debug container
tr '\0' '\n' < /proc/1/environ

The second path is child processes. Environment variables are inherited. When the application runs an external tool such as an image converter or a backup script, the moment that tool dumps the whole environment into its own log, the secret flows into the log collector.

The third is crash reports and debug pages. Here is an antipattern you see in the wild all the time.

// Code you must never write
process.on('uncaughtException', (err) => {
  logger.error({ err, env: process.env }, 'fatal error, dumping context')
  process.exit(1)
})

That single line stores every secret permanently in the log index. Log collection systems usually have broader access than the application does. Framework debug pages carry the same risk. Django debug mode renders configuration values on the exception page, and the Werkzeug debugger even opens an interactive console. Cases of these being left on in staging and exposed to the internet keep recurring.

The fourth is CI logs. Even with secret masking, a transformed value gets through.

# Masking only hides strings that match exactly
set -x
curl -H "Authorization: Bearer $API_TOKEN" https://api.example.com/deploy
echo "$API_TOKEN" | base64        # base64 encoding walks straight past masking
+ curl -H 'Authorization: Bearer ***' https://api.example.com/deploy
+ base64
c2tfbGl2ZV81MU54YlEyTGs5dkhjMHBNejdSdFlhV3E=

The last is container images. A value passed as a build argument stays in the image history verbatim.

docker build --build-arg NPM_TOKEN=npm_9f3aQ2v8Lx -t myapp:1.4.2 .
docker history --no-trunc myapp:1.4.2 | grep -o 'NPM_TOKEN=[A-Za-z0-9_]*'
NPM_TOKEN=npm_9f3aQ2v8Lx

Deleting the file in a RUN step leaves it in the earlier layer. Once pushed to a registry, everyone who pulls that image can read it. Build-time secrets must be passed by mounting.

# syntax=docker/dockerfile:1
FROM node:22-slim
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --omit=dev
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp:1.4.2 .

If the Secret Is Already Committed — Revocation First, History Second

The most common reaction to finding a key in git history is to rewrite the history. That order is wrong. The correct order is revoke, investigate the impact, then clean up the history.

The reason is simple. A credential pushed to a public repository is collected by automated scanners within seconds to minutes. No matter how cleanly you scrub the history, a value that has already been copied cannot be taken back. And rewriting history alone leaves all of the following behind.

Forked repositories, the local clones of developers who already pulled, the pull request references the platform retains (often still reachable if you know the commit hash), caches held by search engines and code search services, and CI caches and artifacts. In other words, rewriting history is not an action that undoes the leak; it is hygiene work that reduces recurrence.

The first priority is invalidation.

# Create and deploy the new key first, then deactivate and delete the old one
aws iam create-access-key --user-name ci-deployer
aws iam update-access-key --access-key-id AKIAIOSFODNN7EXAMPLE \
  --status Inactive --user-name ci-deployer
aws iam delete-access-key --access-key-id AKIAIOSFODNN7EXAMPLE --user-name ci-deployer

The second priority is investigating the impact. Confirm since when and from where that key was used.

aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=AccessKeyId,AttributeValue=AKIAIOSFODNN7EXAMPLE \
  --start-time 2026-07-01T00:00:00Z \
  --query 'Events[].[EventTime,EventName,Username,CloudTrailEvent]' --output text | head -20

If you see unfamiliar IPs, regions you never use, or unexpected API calls, you have to switch to breach response.

The third priority is cleaning up the history. First find which commits it went into.

git log --all --oneline -S 'AKIAIOSFODNN7EXAMPLE'
7c19ae4 chore: add deploy script
2f80b13 fix: correct region in deploy script

For the cleanup, git filter-repo is the standard. filter-branch is slow and error-prone, and is no longer recommended.

cat > replacements.txt <<'EOF'
AKIAIOSFODNN7EXAMPLE==>REDACTED
pr0d-Db-Pass==>REDACTED
EOF

git filter-repo --replace-text replacements.txt --force
git push --force-with-lease --all
git push --force-with-lease --tags

After that you have to tell every collaborator to re-clone. Pushing from an existing clone brings the deleted commits back.

Comparing Storage Options — and the base64 in a Kubernetes Secret Is Not Encryption

The criterion for choosing a secrets management tool is not "is it encrypted." It is how many exposure paths there are, how many minutes revocation and replacement take, and whether you can tell who read it and when.

Storage methodReal exposure pathsRotation costAccess audit
Hardcoded in sourceRepository, forks, clones, code search cachesRequires redeploy, effectively impossibleNone
.env file plus environment varsProcess environment, child processes, crash reports, logsManual deploy, omissions happenNone
Baked into the image (ENV, ARG)All of the above plus image layers, everyone with registry accessImage rebuild and a full rolloutNone
CI variablesBuild logs, fork PR workflows, artifactsReplace in the console, consumers hard to traceLimited
Kubernetes Secret, defaultPlaintext in etcd, everyone with get secrets, pod environmentRequires a restartAPI audit log
Kubernetes Secret plus KMS envelopeEveryone with get secrets, pod environmentRequires a restartAPI audit and KMS
Secrets manager, static valueApplication memory, for the duration of the cache TTLOne console click or API callRecorded per read
Secrets manager, dynamic credentialsOnly short-lived credentials, auto-invalidated after lease expiryAutomatic, no human involvementRecorded per lease
Workload identity (OIDC)No stored static key, only tokens for their lifetimeNothing left to rotateToken issuance records

It is not that lower is better; it is that the further down you go, the shorter your response time during an incident. The row practitioners misread most often in this table is the Kubernetes Secret, so let us start there.

Look at a Secret manifest and the values are base64 encoded, which makes them look encrypted. base64 is an encoding, not encryption. There is no key, and reversing it takes one command.

kubectl get secret app-db -o jsonpath='{.data.password}' | base64 -d; echo
pr0d-Db-Pass

With the default configuration, Secrets are stored in etcd in plaintext. Anyone who obtains an etcd backup file, a snapshot, or a disk image reads every Secret. Let us confirm it.

ETCDCTL_API=3 etcdctl \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key \
  get /registry/secrets/default/app-db | hexdump -C | head -4
00000000  2f 72 65 67 69 73 74 72  79 2f 73 65 63 72 65 74  |/registry/secret|
00000010  73 2f 64 65 66 61 75 6c  74 2f 61 70 70 2d 64 62  |s/default/app-db|
00000020  0a 6b 38 73 00 0a 0c 0a  02 76 31 12 06 53 65 63  |.k8s.....v1..Sec|
00000030  72 65 74 12 8e 01 0a 6f  0a 06 61 70 70 2d 64 62  |ret....o..app-db|

The plaintext is right there. Turn on encryption at rest and the value gets a provider prefix while the body becomes unreadable.

apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets
    providers:
      - kms:
          apiVersion: v2
          name: cloud-kms
          endpoint: unix:///var/run/kmsplugin/socket.sock
      - identity: {}
00000000  2f 72 65 67 69 73 74 72  79 2f 73 65 63 72 65 74  |/registry/secret|
00000020  0a 6b 38 73 3a 65 6e 63  3a 6b 6d 73 3a 76 32 3a  |.k8s:enc:kms:v2:|

identity must always come last in the list. Put it first and you are back to plaintext storage. Changing the configuration does not touch existing Secrets, which stay in plaintext until they are rewritten, so you have to refresh all of them once.

kubectl get secrets --all-namespaces -o json | kubectl replace -f -

The second misconception is RBAC. Anyone with get secrets permission in a namespace reads every Secret in that namespace. It is extremely common for edit permission handed to a developer as a convenience to be, in practice, permission to read production credentials.

kubectl auth can-i get secrets --namespace payments --as dev@example.com
yes

A compromise used often in practice is an external secrets operator. The real value lives in the secrets manager, and only a reference is committed to the cluster.

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: app-db
  namespace: payments
spec:
  refreshInterval: 15m
  secretStoreRef:
    name: aws-secretsmanager
    kind: ClusterSecretStore
  target:
    name: app-db
  data:
    - secretKey: password
      remoteRef:
        key: prod/payments/db
        property: password

This manifest is safe to commit to git, because it contains no value. That said, the operator materializes the value into a cluster Secret, so the RBAC and encryption-at-rest problems above remain exactly as they were. Using a secrets manager does not exempt you from cluster-side hygiene.

A Structure That Removes Static Keys — Short-Lived Credentials and OIDC Federation

Everything discussed so far has been about "where to put the static key." A better answer is not to create static keys at all.

The old way to access the cloud from CI was to create an access key and paste it into a CI variable. That key never expires, you cannot tell who copied it, and rotation depends on a human remembering. Today the runner exchanges an OIDC token it was issued for cloud credentials.

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
          aws-region: ap-northeast-2
      - run: aws sts get-caller-identity
{
    "UserId": "AROAY3EXAMPLEID:GitHubActions",
    "Account": "123456789012",
    "Arn": "arn:aws:sts::123456789012:assumed-role/gha-deploy/GitHubActions"
}

There is no stored key. The credentials are valid only for the duration of the job run.

The configuration mistake that comes up most often here is the condition clause in the trust policy. The following is dangerous.

{
  "Condition": {
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:acme/*"
    }
  }
}

This condition lets any repository, any branch, any pull request in the organization assume the role. It creates a path for a PR workflow raised from a fork to obtain the production deploy role. You have to pin it down to the branch or the environment.

{
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
      "token.actions.githubusercontent.com:sub": "repo:acme/payments:environment:production"
    }
  }
}

Kubernetes pods use the same structure. A projected service account token can specify an audience and an expiration, and the cloud STS verifies it.

spec:
  serviceAccountName: payments
  volumes:
    - name: aws-token
      projected:
        sources:
          - serviceAccountToken:
              audience: sts.amazonaws.com
              expirationSeconds: 3600
              path: token

Database credentials can be created dynamically too. The Vault database secrets engine creates a user at request time and deletes it when the lease ends.

vault read database/creds/payments-readonly
Key                Value
---                -----
lease_id           database/creds/payments-readonly/9tKq2xL0
lease_duration     1h
lease_renewable    true
password           A1a-3mQx9Zr7Kt2Vb0Ns
username           v-token-payments-readonly-8Xk1qP

This credential disappears on its own an hour later. Even if it leaks the window is narrow, and the username stays in the logs so you can trace which request it was issued for. Shrinking the lifetime to one hour is almost always more effective than straining to hide a static key perfectly.

The Design That Makes Rotation Possible — Two Valid Keys

Rotation usually fails for reasons that have nothing to do with storage. It fails because the code assumes "there is exactly one valid key." Under that assumption, swapping a key necessarily creates a momentary window of mismatch, and so nobody ever rotates.

The fix is to separate verification from signing. Always sign with a single current key, and verify against the entire set of valid keys.

Take webhook signature verification as an example. The fragile implementation looks like this.

import hmac, hashlib, os

SECRET = os.environ["WEBHOOK_SECRET"]

def verify(body: bytes, signature: str) -> bool:
    expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

The moment you change the key, every request signed with the previous key is rejected. Since there is no way to change the sender and the receiver atomically at the same instant, replacement is effectively impossible.

Turn it into a set and replacement becomes possible.

import hmac, hashlib, os

# Comma-separated list of valid keys. The first one is the current signing key.
KEYS = [k.strip() for k in os.environ["WEBHOOK_SECRETS"].split(",") if k.strip()]

def sign(body: bytes) -> str:
    return hmac.new(KEYS[0].encode(), body, hashlib.sha256).hexdigest()

def verify(body: bytes, signature: str) -> bool:
    for key in KEYS:
        expected = hmac.new(key.encode(), body, hashlib.sha256).hexdigest()
        if hmac.compare_digest(expected, signature):
            return True
    return False

Now the replacement procedure works with zero downtime.

Step 1  Append the new key to the valid list and deploy   → sign with old key, verify both
Step 2  Move the new key to the front and deploy          → sign with new key, verify both
Step 3  Confirm requests signed with the old key are gone → confirm via metrics
Step 4  Remove the old key from the list and deploy       → only the new key is valid
Step 5  Revoke the old key at the issuer

To see step 3 with your own eyes, you have to record which key matched as a metric.

def verify(body: bytes, signature: str) -> bool:
    for index, key in enumerate(KEYS):
        expected = hmac.new(key.encode(), body, hashlib.sha256).hexdigest()
        if hmac.compare_digest(expected, signature):
            metrics.increment("webhook.signature.verified", tags=[f"key_index:{index}"])
            return True
    metrics.increment("webhook.signature.failed")
    return False

When the counter for key_index:1 reaches zero, it is safe to delete the old key. Rotation becomes something you drive by observation rather than guesswork.

For JWT the same principle is implemented with the kid header and JWKS. The issuer publishes the new key in JWKS first, and only changes the signing key after verifiers have picked it up. For database accounts, alternating between two users is the standard approach. Refresh app_a on even rounds and app_b on odd rounds, and have the application read the currently active user from the secrets manager. At no moment is there fewer than one valid credential, so there is no outage.

The core point is this. Rotation is not an operational procedure but a design property. If the code assumes a single key, no secrets manager, however good, will make actual replacement happen.

Detection — What Pre-commit Hooks and Repository Scanning Cannot Do

The last layer is detection. The tooling is mature.

# Scan the working tree and the whole history
gitleaks detect --source . --redact --report-format json --report-path leaks.json

# Check only the staged changes right before a commit
gitleaks protect --staged --redact
Finding:     STRIPE_SECRET_KEY=sk_live_REDACTED
Secret:      REDACTED
RuleID:      stripe-access-token
File:        deploy/staging.env
Line:        14
Commit:      7c19ae4a2f3b18c0d95e7f4a6b2c1d80e3f9a5b6

Wire it up as a pre-commit hook and it gets caught on the developer machine.

repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.4
    hooks:
      - id: gitleaks

Scanners with verification cut the noise dramatically. They call the real API with the value they found and report only the keys that are alive.

trufflehog git file://. --only-verified --json | jq -r '.DetectorName + " " + .Verified'

That is what the tools do for you. Here are the limits.

A pre-commit hook is local configuration, so it gets bypassed. One git commit --no-verify is all it takes, and a new joiner spends days without the hook installed. Real enforcement comes from server-side push protection. Treat the hook as a convenience and the server check as the control.

Detection rules only work well on values with a distinctive shape. An access key beginning with AKIA or a token with the sk_live_ prefix is caught exactly, but a shapeless value like a database password or the API key of a private service depends on entropy estimation. Entropy rules produce many false positives, which pushes you to raise the threshold, and then you miss the real passwords.

And scanners only look at code. Copies pasted into wikis, issue attachments, chat logs, spreadsheets and local notes are out of scope. In real incidents, the leak point is quite often not the repository at all.

So there is one thing to check before investing in detection: assuming this secret is exposed, how many minutes do revocation and replacement take? If that number is large, no amount of dense detection will reduce the loss during an incident. Conversely, if that number is small, late detection still means limited damage. The priority is always to shorten response time first.

Wrapping Up — Not Whether You Hid It, but How Many Minutes It Takes to Change It

Three sentences to summarize.

An environment variable is a delivery mechanism, not a storage location. The process environment is readable from the same host, and it keeps leaking into logs, crash reports and image layers. .gitignore blocks exactly one of those paths.

When a secret is exposed, the order is revoke, investigate, clean up the history. Do it the other way around and you spend your time on cleanup while the already copied value stays live.

And the surest improvement is to reduce static keys. Move CI to OIDC federation, move databases to dynamic credentials, and make whatever static keys remain replaceable at any time through a valid-key-set design.

One question is enough to measure a team's secrets management maturity. Assuming this credential were published on the internet right now, how many minutes would it take to revoke it, replace it with a new value, and bring the service back to normal? If the answer is measured in hours, you should build the rotation path before buying more tools.