CI 파이프라인이 클라우드에 배포하려면 인증이 필요하다. 가장 흔한 방식은 콘솔에서 장기 액세스 키를 발급받아 CI 시크릿 저장소에 넣어두는 것이다. 처음엔 간단하지만, 시간이 지나면 “누가 언제 발급했는지 모를 키”가 여기저기 흩어지고, 유출되면 무기한 악용될 수 있으며, 주기적으로 로테이션해야 하는 운영 부담까지 떠안게 된다.

OIDC(OpenID Connect) 기반 키리스 인증은 이 문제를 근본부터 없앤다. 잡이 실행될 때마다 CI 제공자가 서명한 단기 신원 토큰(ID token)을 발급하고, 클라우드는 이를 검증한 뒤 딱 그 잡에만 유효한 임시 자격증명을 내준다. 저장할 장기 비밀이 아예 없으니 유출도 로테이션도 성립하지 않는다. 이 글에서는 페더레이션의 동작 원리, AWS·GCP·Azure별 설정, 신뢰 조건을 좁히는 방법, 흔한 함정까지 실전 설정과 함께 정리한다.

왜 장기 키가 문제인가

CI에 넣어둔 장기 액세스 키는 세 가지 구조적 약점을 갖는다. 첫째, 수명이 없다. 명시적으로 폐기하기 전까지 영원히 유효하므로, 로그·캐시·포크된 저장소 어디로든 새어나가면 그 순간부터 무기한 공격 표면이 된다. 둘째, 범위가 넓다. 편의상 “배포에 필요한 모든 권한”을 한 키에 몰아주기 쉽고 그 키는 CI의 모든 잡이 공유한다. 셋째, 추적이 어렵다. 같은 키를 여러 저장소가 재사용하면 사고 시 어느 잡이 무엇을 했는지 되짚기 힘들다.

OIDC는 이 세 가지를 각각 뒤집는다. 자격증명에 짧은 만료를 주고, 신뢰 정책으로 어느 저장소·브랜치·환경이 어떤 역할을 맡을지 못박으며, 매 잡마다 독립된 토큰으로 감사 추적을 남긴다.

OIDC 페더레이션은 어떻게 동작하는가

핵심 흐름은 토큰 교환(token exchange)이다. CI 제공자는 OIDC 아이덴티티 프로바이더(IdP) 역할을 하며, 잡이 시작되면 그 정체를 담은 JWT를 발급한다. 토큰의 iss(발급자)는 CI 제공자 URL, sub(주체)에는 저장소·브랜치·환경 식별 정보가 들어간다. 클라우드는 서명과 클레임을 검증한 뒤 신뢰 관계에 맞으면 임시 자격증명을 발급한다.

검증의 뿌리는 IdP가 공개하는 디스커버리 문서다. {issuer}/.well-known/openid-configuration에서 JWKS(공개키 집합) 위치를 알아내 그 공개키로 서명을 확인한다. 서명이 유효하다는 것은 “이 토큰을 정말 그 CI 제공자가 발급했다”는 증명이며, 여기에 aud와 sub를 대조해 “우리가 신뢰하기로 한 바로 그 잡”인지 확인한다.

# CI가 발급한 OIDC 토큰의 디코딩된 페이로드 예시
# (실제로는 헤더.페이로드.서명 형태의 JWT 문자열)
{
  "iss": "https://token.actions.githubusercontent.com",  # 발급자
  "aud": "sts.amazonaws.com",                             # 대상(클라우드가 지정)
  "sub": "repo:acme/payments:ref:refs/heads/main",        # 주체: 저장소+브랜치
  "environment": "production",
  "iat": 1754400000,   # 발급 시각
  "exp": 1754400900    # 만료: 발급 후 15분
}

결정적인 부분은 sub 같은 클레임이 런타임에 자동으로 채워진다는 점이다. 잡이 main에서 돌면 ref:refs/heads/main이 박히고 다른 브랜치면 다른 값이 박힌다. 공격자가 위조할 수 없고 신뢰 정책이 이 값을 검사하므로 접근이 정확히 제한된다.

AWS: IAM 역할과 웹 아이덴티티

AWS에서는 CI 제공자를 OIDC 아이덴티티 프로바이더로 등록하고 sts:AssumeRoleWithWebIdentity로 역할을 맡는다. 먼저 IAM에 프로바이더를 만든다.

# AWS에 CI 제공자를 OIDC IdP로 등록 (1회)
aws iam create-open-id-connect-provider 
  --url https://token.actions.githubusercontent.com 
  --client-id-list sts.amazonaws.com   # 토큰 aud 와 일치

다음으로 이 프로바이더가 맡을 IAM 역할을 만들고 신뢰 정책에 조건을 건다. sub를 특정 저장소·브랜치로 좁히는 것이 보안의 핵심이며, 조건을 빼면 그 프로바이더로 인증한 누구나 역할을 맡을 수 있어 위험하다.

{
  "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": {
        // main 브랜치의 잡만 이 역할을 맡음
        "token.actions.githubusercontent.com:sub": "repo:acme/payments:ref:refs/heads/main"
      }
    }
  }]
}

파이프라인 쪽에는 저장할 시크릿이 없다. 잡에 토큰 발급 권한만 주면 CI가 토큰을 만들어 역할을 맡는다.

# GitHub Actions: 시크릿 없이 AWS 역할 맡기
permissions:
  id-token: write   # OIDC 토큰 발급 권한 (필수)
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/ci-payments-deploy  # 키 대신 역할 ARN
          aws-region: ap-northeast-2
      - run: aws s3 sync ./dist s3://payments-artifacts/  # 임시 자격증명으로 실행

GCP와 Azure: 같은 원리, 다른 이름

GCP는 Workload Identity Federation이라는 이름으로 같은 개념을 제공한다. 워크로드 아이덴티티 풀과 프로바이더를 만들고, CI 토큰 클레임을 GCP 속성으로 매핑한 뒤 조건으로 접근을 제한한다.

# GCP 워크로드 아이덴티티 풀 + OIDC 프로바이더 생성
gcloud iam workload-identity-pools create ci-pool 
  --location=global --display-name="CI Pool"

gcloud iam workload-identity-pools providers create-oidc github 
  --location=global --workload-identity-pool=ci-pool 
  --issuer-uri="https://token.actions.githubusercontent.com" 
  --allowed-audiences="https://iam.googleapis.com/projects/.../providers/github" 
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" 
  --attribute-condition="assertion.repository=='acme/payments'"  # 특정 저장소만 허용

Azure는 Federated Identity Credential을 앱 등록(또는 관리 ID)에 붙인다. issuer, subject, audience 세 값으로 신뢰를 정의하며, 이 조합이 토큰과 일치할 때만 Azure AD 액세스 토큰으로 교환된다.

# Azure: 앱 등록에 페더레이션 자격증명 추가
az ad app federated-credential create 
  --id $APP_OBJECT_ID 
  --parameters '{
    "name": "github-main",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:acme/payments:ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"]
  }'

세 클라우드의 UI와 용어는 다르지만 뼈대는 동일하다. 발급자를 등록하고, 대상(aud)을 고정하고, 주체(sub) 조건으로 접근을 좁힌다. 이 세 축만 이해하면 어떤 조합이든 옮겨 설정할 수 있다.

신뢰 조건을 좁히는 것이 전부다

OIDC의 안전성은 신뢰 정책이 얼마나 정확하게 조건을 거느냐에 달려 있다. 가장 흔하고 위험한 실수는 sub 조건을 저장소 이름까지만 걸고 와일드카드로 브랜치를 열어두는 것이다.

// 위험: 이 저장소의 모든 브랜치·태그·PR이 프로덕션 역할을 맡을 수 있음
"StringLike": {
  "token.actions.githubusercontent.com:sub": "repo:acme/payments:*"
}

// 안전: 브랜치 또는 보호된 환경으로 못박음
"StringLike": {
  "token.actions.githubusercontent.com:sub": "repo:acme/payments:environment:production"
}

저장소 전체를 열어두면 누군가 임의의 브랜치를 만들어 워크플로를 돌리기만 해도 프로덕션 자격증명을 손에 넣는다. 따라서 브랜치, 태그, 또는 CI의 보호된 환경(environment)을 sub 조건에 반드시 포함해야 한다.

  • 브랜치 고정: ref:refs/heads/main — 특정 브랜치의 잡만 허용
  • 태그 고정: ref:refs/tags/v* — 릴리스 태그 배포 전용
  • 환경 고정: environment:production — 승인 게이트가 걸린 환경에서만

가장 견고한 조합은 CI의 보호된 환경과 OIDC 조건을 함께 쓰는 것이다. 환경에 필수 승인자를 걸고 신뢰 정책이 environment:production을 요구하면, 승인 없이는 애초에 그 sub를 가진 토큰이 발급되지 않는다.

임시 자격증명의 수명과 권한 최소화

OIDC로 받는 자격증명은 기본적으로 짧게 살지만, 여기에 안주하지 말고 실제 필요한 만큼만 더 조여야 한다. AWS라면 role-duration-seconds: 900처럼 세션 수명을 배포 시간에 맞춰 15분 정도로 줄여, 잡이 끝나면 자격증명이 자연 만료되게 한다.

또한 배포용 역할에 AdministratorAccess 같은 광범위한 정책을 붙이면 OIDC를 쓰는 의미가 반쯤 사라진다. 키를 없앴어도 역할이 모든 것을 할 수 있으면 잡 탈취 시 피해 범위는 여전히 크다. 역할 권한은 그 잡이 실제로 만지는 리소스만 담아, 예컨대 아티팩트 버킷에 대한 s3:PutObject·s3:ListBucket 정도로 한정한다.

흔한 함정과 디버깅

OIDC 설정에서 막히는 지점은 대부분 정해져 있다. 에러 메시지가 모호할 때 아래를 짚으면 빠르게 원인을 찾는다.

  • aud 불일치: 토큰의 aud와 클라우드가 기대하는 대상이 다르면 검증에 실패한다. AWS는 보통 sts.amazonaws.com, GCP·Azure는 프로바이더별 고유 값이다. IdP의 client-id 목록과 파이프라인이 요청하는 audience를 함께 확인한다.
  • sub 형식 오타: repo:org/name:ref:refs/heads/main의 콜론·경로가 한 글자만 어긋나도 매칭되지 않는다. 콘솔에서 실제 발급된 sub를 그대로 복사해 넣는 것이 확실하다.
  • 토큰 발급 권한 누락: permissions: id-token: write가 없으면 토큰이 발급되지 않는다. “credentials not found”류 에러의 흔한 원인이다.
  • issuer 경로 오타: 발급자 URL은 끝의 슬래시까지 정확해야 하며, .well-known/openid-configuration이 응답하는지 curl로 확인하면 좋다.

토큰을 눈으로 확인하려면 잡 안에서 클레임을 출력해볼 수 있다. 단, 이 토큰은 자격증명이므로 로그에 그대로 노출하지 말고 구조만 확인한 뒤 제거한다.

# 발급된 OIDC 토큰의 클레임만 확인 (서명 검증 없이 페이로드만 디코딩)
import base64, json, sys

def claims(jwt):
    p = jwt.split(".")[1]              # 헤더.페이로드.서명 중 가운데
    p += "=" * (-len(p) % 4)          # base64url 패딩 보정
    return json.loads(base64.urlsafe_b64decode(p))

c = claims(sys.stdin.read().strip())
# 신뢰 조건에 쓰이는 클레임만 출력 (서명은 버림)
for k in ("iss", "aud", "sub", "environment"):
    print(f"{k}: {c.get(k)}")

마무리

OIDC 키리스 인증의 요점은 “저장할 비밀을 없애 유출·로테이션 문제 자체를 제거한다”는 것이다. CI가 발급한 단기 토큰을 클라우드가 검증하고 그 잡에만 유효한 임시 자격증명을 내주는 구조다. 설정의 뼈대는 어느 클라우드든 같다. 발급자를 등록하고, 대상을 고정하고, 주체(sub) 조건으로 저장소·브랜치·환경을 못박는다.

다만 OIDC를 켰다는 사실만으로 안전이 보장되지는 않는다. sub 조건을 저장소 전체로 열어두거나 역할에 과도한 권한을 붙이면 키를 없앤 이점이 상당 부분 사라진다. 진짜 이득은 조건을 최대한 좁히고, 세션 수명을 짧게 하고, 권한을 최소화했을 때 나온다.

자주 묻는 질문

Q. OIDC를 쓰면 시크릿이 정말 하나도 필요 없나요?
A. 클라우드 인증에 한해서는 그렇습니다. 저장하는 것은 “맡을 역할의 ARN”이나 프로바이더 ID 같은 식별자뿐이고 이건 공개돼도 무방한 값입니다. 다만 서드파티 API 키나 DB 비밀번호처럼 OIDC로 대체되지 않는 시크릿은 여전히 별도로 관리해야 합니다.

Q. 여러 브랜치·환경에서 배포해야 하는데 조건을 어떻게 관리하나요?
A. 환경별로 별도 역할을 만들고 각 역할에 해당 환경의 sub 조건만 거는 것이 깔끔합니다. 스테이징과 프로덕션 역할을 분리하면 권한과 감사 추적이 모두 명확해지고, 프로덕션 역할일수록 조건을 좁히고 승인 게이트를 함께 걸기를 권합니다.