GitHub Actions 워크플로우에 AWS Access Key를 하드코딩해서 Push한 적이 있다면, 이미 한 번은 위험에 노출된 적이 있습니다. 워크플로우 파일은 Git 히스토리에 남고, Fork된 저장소에서도 보입니다. 이 글에서는 CI/CD 파이프라인에서 인증 정보를 안전하게 다루는 방법을 단계별로 정리합니다.
핵심 요약
- GitHub Actions Secret은 저장 시 libsodium sealed box로 암호화되며, 로그에 출력되면 자동으로
***로 마스킹됩니다. - Secret의 범위는 Repository, Environment, Organization 3단계로 나뉘며, 최소 권한 원칙에 따라 가장 좁은 범위를 선택해야 합니다.
- Environment Secret에 보호 규칙(Required Reviewers, Wait Timer)을 적용하면 프로덕션 배포 전 승인 절차를 강제할 수 있습니다.
- 장기 자격 증명(Access Key) 대신 OIDC(OpenID Connect)를 사용하면 Secret 자체를 제거할 수 있어 가장 안전합니다.
- Fork된 PR에서는 Secret이 노출되지 않도록
pull_request_target트리거 사용 시 주의가 필요합니다.
1. 왜 Secret 관리가 중요한가
3명이 운영하는 SaaS 서비스에서 GitHub Actions로 배포 파이프라인을 구성했습니다. 빠르게 만들기 위해 AWS Access Key를 Repository Secret에 저장했습니다.
6개월이 지나자 이런 문제가 발생합니다.
- 팀원이 퇴사했는데, 해당 Key에 AdministratorAccess 정책이 붙어 있습니다. Key를 교체하려면 모든 워크플로우를 수정해야 합니다.
- 스테이징 배포용 Secret과 프로덕션 배포용 Secret이 같은 레벨에 섞여 있어, 개발 브랜치에서 실수로 프로덕션에 배포됩니다.
- 외부 기여자가 Fork해서 PR을 올렸는데, 워크플로우가 자동 실행되면서 Secret이 외부 서버로 전송될 수 있는 구조입니다.
이런 상황은 "Secret을 어디에 저장하느냐"만의 문제가 아닙니다. 범위, 수명, 접근 제어, 교체 주기까지 설계해야 합니다.
2. GitHub Actions Secret의 동작 원리
암호화 저장
GitHub은 Secret을 libsodium sealed box 방식으로 암호화하여 저장합니다. 저장된 Secret은 GitHub UI에서도 값을 다시 확인할 수 없으며, 업데이트만 가능합니다.
Secret 등록 → libsodium sealed box 암호화 → GitHub 서버에 저장
워크플로우 실행 → Runner에 복호화된 값 주입 → 환경 변수로 사용
로그 마스킹
워크플로우 실행 중 Secret 값이 로그에 출력되면, GitHub은 자동으로 ***로 치환합니다. 다만 이 마스킹에는 한계가 있습니다.
| 상황 | 마스킹 여부 | 설명 |
|---|---|---|
echo ${{ secrets.MY_KEY }} |
✅ 마스킹됨 | 직접 참조 시 자동 마스킹 |
| Secret을 base64 인코딩 후 출력 | ❌ 마스킹 안 됨 | 변환된 값은 원본과 다르므로 감지 불가 |
| Secret을 JSON 필드에 포함해서 출력 | ⚠️ 부분 마스킹 | 문자열 경계에 따라 다름 |
| 구조화된 로그에서 분할 출력 | ❌ 마스킹 안 됨 | 여러 줄로 나뉘면 감지 불가 |
운영 환경에서는 ::add-mask:: 커맨드로 동적으로 생성되는 값도 마스킹 대상에 추가하는 것이 안전합니다.
- name: Mask dynamic token
run: |
TOKEN=$(curl -s https://auth.example.com/token)
echo "::add-mask::$TOKEN"
echo "TOKEN=$TOKEN" >> $GITHUB_ENV
Secret의 접근 범위
Secret은 3단계 범위를 가집니다.
| 범위 | 적용 대상 | 설정 위치 | 사용 사례 |
|---|---|---|---|
| Organization Secret | Org 내 선택된 저장소 | Organization Settings | 공통 NPM Token, Docker Hub 인증 |
| Repository Secret | 해당 저장소 전체 | Repository Settings | 프로젝트 고유 API Key |
| Environment Secret | 특정 Environment만 | Environment Settings | 프로덕션 AWS 자격 증명 |
우선순위: Environment > Repository > Organization (같은 이름일 경우 좁은 범위가 우선)
3. 실무 시나리오별 Secret 설계
시나리오 1: 단일 서비스, 환경 분리
스테이징과 프로덕션을 분리해서 배포하는 백엔드 서비스를 운영합니다.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main, develop]
jobs:
deploy-staging:
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Deploy to Staging
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: ./deploy.sh staging
deploy-production:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy to Production
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: ./deploy.sh production
이 구조에서 핵심은 environment 키워드입니다. staging Environment에 등록된 Secret과 production Environment에 등록된 Secret은 완전히 다른 값입니다. 같은 이름이지만 접근 범위가 다르기 때문에, 개발 브랜치에서 프로덕션 Secret에 접근할 수 없습니다.
시나리오 2: 프로덕션 배포에 승인 절차 추가
Environment에 보호 규칙(Protection Rules)을 설정하면 배포 전 승인을 강제할 수 있습니다.
GitHub UI 설정: - Settings → Environments → production - Required Reviewers: 팀 리더 또는 SRE 지정 - Wait Timer: 5분 (실수 배포 방지) - Deployment Branches: main 브랜치만 허용
이렇게 설정하면 production Environment의 Secret을 사용하는 Job은 지정된 Reviewer가 승인할 때까지 대기합니다. main 브랜치가 아닌 브랜치에서는 해당 Environment를 참조하는 Job 자체가 실행되지 않습니다.
시나리오 3: 여러 저장소에서 공통 Secret 사용
Organization에 10개 마이크로서비스 저장소가 있고, 모두 같은 Docker Hub 계정으로 이미지를 Push합니다.
이 경우 Organization Secret을 사용합니다.
- Organization Settings → Secrets → Actions
DOCKER_USERNAME,DOCKER_PASSWORD등록- 접근 가능 저장소: 전체 또는 선택된 저장소만 지정
주의할 점은 Organization Secret에 접근할 수 있는 저장소 목록을 주기적으로 검토해야 한다는 것입니다. 저장소가 추가될 때 자동으로 Secret 접근 권한이 부여되는 설정("All repositories")은 최소 권한 원칙에 위배됩니다.
4. OIDC로 Secret 자체를 제거하기
장기 자격 증명(Access Key)을 Secret에 저장하는 방식의 근본적인 문제는 Key가 유출되면 교체할 때까지 무제한으로 악용될 수 있다는 점입니다. OIDC(OpenID Connect)를 사용하면 이 문제를 구조적으로 해결할 수 있습니다.
OIDC 동작 원리
1. GitHub Actions Runner가 GitHub OIDC Provider에 JWT 토큰을 요청
2. JWT에는 저장소명, 브랜치, 워크플로우 정보가 포함됨
3. Cloud Provider(AWS, Azure, GCP)가 JWT를 검증
4. 검증 통과 시 임시 자격 증명(보통 1시간) 발급
5. 워크플로우는 임시 자격 증명으로 Cloud 리소스에 접근
AWS OIDC 연동 설정
1단계: AWS에서 OIDC Provider 등록
# AWS CLI로 GitHub OIDC Provider 등록
aws iam create-open-id-connect-provider \
--url "https://token.actions.githubusercontent.com" \
--client-id-list "sts.amazonaws.com" \
--thumbprint-list "1b511abead59c6ce207077c0bf0e0043b1382612"
thumbprint는 GitHub의 인증서 체인이 변경되면 업데이트가 필요합니다. AWS 콘솔에서 OIDC Provider를 등록하면 thumbprint를 자동으로 가져오므로, 콘솔 사용이 더 안정적입니다.
2단계: IAM Role 생성 (Trust Policy)
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}
]
}
Condition의 sub 필드가 핵심입니다. repo:my-org/my-repo:ref:refs/heads/main으로 설정하면, 해당 저장소의 main 브랜치에서 실행된 워크플로우만 이 Role을 Assume할 수 있습니다.
3단계: 워크플로우에서 사용
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC 토큰 요청에 필요
contents: read
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
aws-region: ap-northeast-2
- run: aws s3 ls # 임시 자격 증명으로 실행
OIDC vs Access Key 비교
| 기준 | Access Key (Secret) | OIDC |
|---|---|---|
| 유효 기간 | 무기한 (수동 교체) | 1시간 (자동 만료) |
| 유출 시 영향 | 교체할 때까지 악용 가능 | 만료 시간 이후 무효 |
| 범위 제한 | Secret 이름으로만 구분 | 저장소, 브랜치, 환경 단위 제한 |
| 교체 부담 | 수동 교체 + 워크플로우 수정 | 교체 불필요 (토큰은 매번 새로 발급) |
| 설정 복잡도 | 낮음 (Key 등록만) | 중간 (OIDC Provider + IAM Role 설정) |
운영 환경에서는 OIDC를 기본으로 사용하고, OIDC를 지원하지 않는 서비스(일부 SaaS API)에 대해서만 Secret을 사용하는 것이 권장됩니다.
5. Secret 유출 방지 전략
Fork PR에서의 Secret 보호
오픈소스 저장소에서 외부 기여자가 Fork하여 PR을 올리면, 기본적으로 해당 PR 워크플로우에서는 Secret에 접근할 수 없습니다. 이것은 GitHub의 의도된 보안 설계입니다.
다만 pull_request_target 트리거를 사용하면 Fork PR에서도 Secret에 접근할 수 있는데, 이것은 위험합니다.
# ⚠️ 위험한 패턴
on:
pull_request_target: # Fork PR에서도 Secret 접근 가능
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # Fork의 코드를 체크아웃
- run: ./build.sh # Fork의 코드가 Secret에 접근 가능
env:
API_KEY: ${{ secrets.API_KEY }}
이 패턴은 외부 기여자가 build.sh를 수정해서 Secret을 외부로 전송하는 코드를 삽입할 수 있습니다.
안전한 패턴:
# ✅ 안전한 패턴: 신뢰할 수 있는 코드만 실행
on:
pull_request_target:
jobs:
label:
runs-on: ubuntu-latest
steps:
# base 브랜치의 코드만 실행 (Fork 코드를 체크아웃하지 않음)
- uses: actions/checkout@v4
- run: ./scripts/auto-label.sh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Secret Scanning과 Push Protection
GitHub Advanced Security를 사용하면 Secret Scanning이 활성화됩니다.
- Secret Scanning: 저장소에 커밋된 Secret(API Key, Token 등)을 자동 감지
- Push Protection: Secret이 포함된 커밋을 Push할 때 차단
무료 플랜에서도 Public 저장소는 Secret Scanning이 기본 활성화되어 있습니다. Private 저장소는 GitHub Advanced Security 라이선스가 필요합니다.
워크플로우 수준 보안 강화
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read # 최소 권한만 부여
id-token: write # OIDC 필요 시에만
# packages: write # 불필요하면 명시하지 않음
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
# Secret은 필요한 Step에서만 주입
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
run: ./deploy.sh
핵심 원칙: - permissions를 Job 레벨에서 명시적으로 선언합니다. 선언하지 않은 권한은 자동으로 none이 됩니다. - Secret은 Job 전체가 아니라 필요한 Step에서만 환경 변수로 주입합니다. - GITHUB_TOKEN의 기본 권한도 Repository Settings에서 "Read repository contents permission"으로 제한할 수 있습니다.
6. Secret 교체(Rotation) 전략
수동 교체 절차
Secret을 정기적으로 교체해야 하는 경우(예: 90일마다), 아래 절차를 따릅니다.
- 새로운 Key/Token 발급 (기존 Key는 아직 유효한 상태로 유지)
- GitHub Secret 업데이트 (새 값으로 교체)
- 워크플로우 실행하여 새 Secret이 정상 동작하는지 확인
- 기존 Key 비활성화 또는 삭제
중요한 것은 1번과 4번 사이에 겹치는 시간(Grace Period)을 두는 것입니다. 동시에 교체하면 진행 중인 워크플로우가 실패합니다.
자동화된 교체
AWS Secrets Manager와 연동하면 Secret 교체를 자동화할 수 있습니다.
# Secret Rotation 알림 워크플로우
name: Secret Rotation Reminder
on:
schedule:
- cron: '0 9 1 */3 *' # 3개월마다 매월 1일 9시
jobs:
check-rotation:
runs-on: ubuntu-latest
steps:
- name: Check secret age
uses: actions/github-script@v7
with:
script: |
const { data: secrets } = await github.rest.actions.listRepoSecrets({
owner: context.repo.owner,
repo: context.repo.repo
});
const now = new Date();
for (const secret of secrets.secrets) {
const updated = new Date(secret.updated_at);
const daysSinceUpdate = (now - updated) / (1000 * 60 * 60 * 24);
if (daysSinceUpdate > 90) {
core.warning(`Secret ${secret.name} was last updated ${Math.floor(daysSinceUpdate)} days ago`);
}
}
다만 OIDC를 사용하면 이 교체 부담 자체가 사라집니다. 교체가 필요한 Secret의 수를 줄이는 것이 가장 효과적인 전략입니다.
7. 보안 설계 체크리스트
| 항목 | 확인 사항 | 우선순위 |
|---|---|---|
| OIDC 전환 | AWS, Azure, GCP 자격 증명을 OIDC로 전환했는가 | 상 |
| Environment 분리 | Production Secret이 별도 Environment에 격리되어 있는가 | 상 |
| 승인 규칙 | Production Environment에 Required Reviewer가 설정되어 있는가 | 상 |
| 브랜치 제한 | Production Environment의 Deployment Branch가 main으로 제한되어 있는가 | 상 |
| permissions 명시 | 모든 워크플로우에서 최소 권한 permissions를 선언했는가 | 중 |
| Fork PR 보호 | pull_request_target에서 Fork 코드를 체크아웃하지 않는가 |
중 |
| Secret Scanning | Secret Scanning + Push Protection이 활성화되어 있는가 | 중 |
| 교체 주기 | OIDC 전환이 불가한 Secret에 교체 주기가 설정되어 있는가 | 중 |
| Organization Secret 범위 | "All repositories" 대신 선택적 저장소 접근으로 제한했는가 | 하 |
| Step 단위 주입 | Secret이 Job 전체가 아닌 필요한 Step에서만 참조되는가 | 하 |
8. 선택 가이드: 상황별 Secret 관리 방식
| 상황 | 권장 방식 | 이유 |
|---|---|---|
| AWS/Azure/GCP 접근 | OIDC | 장기 자격 증명 제거, 자동 만료, 브랜치 단위 제한 가능 |
| SaaS API Key (Slack, Datadog 등) | Environment Secret | OIDC 미지원, 환경별 분리 필요 |
| Docker Hub, NPM Token (공통) | Organization Secret | 여러 저장소에서 공유, 중앙 관리 |
| 데이터베이스 비밀번호 | Environment Secret + 외부 Secret Manager 연동 | 교체 자동화 필요, 접근 감사 필요 |
| 내부 테스트용 토큰 | Repository Secret | 범위가 좁고 영향도 낮음 |
9. 외부 Secret Manager 연동
GitHub Actions Secret만으로 부족한 경우, 외부 Secret Manager를 연동할 수 있습니다.
AWS Secrets Manager 연동
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
aws-region: ap-northeast-2
- name: Get secrets from AWS Secrets Manager
uses: aws-actions/aws-secretsmanager-get-secrets@v2
with:
secret-ids: |
DB,prod/database/credentials
API,prod/api/external-keys
- name: Use retrieved secrets
run: |
echo "::add-mask::$DB_PASSWORD"
./deploy.sh
env:
DB_HOST: ${{ env.DB_HOST }}
DB_PASSWORD: ${{ env.DB_PASSWORD }}
이 패턴의 장점: - Secret의 실제 값은 AWS Secrets Manager에서 관리 (교체 자동화, 접근 로그, 버전 관리) - GitHub에는 OIDC Role ARN만 설정 (유출돼도 직접적인 위험 없음) - Secret Manager의 자동 Rotation 기능 활용 가능
HashiCorp Vault 연동
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: hashicorp/vault-action@v3
with:
url: https://vault.example.com
method: jwt
role: github-actions-role
secrets: |
secret/data/prod/db password | DB_PASSWORD ;
secret/data/prod/api key | API_KEY
- name: Deploy with secrets
run: ./deploy.sh
env:
DB_PASSWORD: ${{ env.DB_PASSWORD }}
API_KEY: ${{ env.API_KEY }}
10. 관련 글
'Security' 카테고리의 다른 글
| DevSecOps란 무엇인가: CI/CD에 보안을 통합하는 방법 (0) | 2026.06.07 |
|---|---|
| IAM과 RBAC 차이: AWS, Azure, GCP 기준으로 이해하기 (0) | 2026.06.05 |
| AWS IAM Role과 Policy 차이: 권한을 설계하는 두 가지 축 (0) | 2026.05.29 |
| 클라우드 보안 기본 원칙: 최소 권한, 네트워크 격리, 감사 로그 (0) | 2026.05.28 |