들어가며
GitHub Actions에서 AWS 리소스를 다루려면 어떤 식으로든 자격증명이 필요하다. Github Actions에서 쓰는 방식을 정리하면 대략 이렇다.
| 방식 | 설명 | 장점 | 단점 |
|---|---|---|---|
| OIDC + IAM Role Assume | GitHub이 발급한 OIDC 토큰으로 IAM Role을 임시 Assume | 장기 키 관리 불필요, 레포·브랜치 단위 제한 가능 | 초기 설정이 조금 복잡 |
| IAM User Access Key | GitHub Secrets에 AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY 저장 | 설정이 매우 간단 | 키 유출 위험, 주기적 로테이션 필요 |
| Self-hosted Runner + IAM Role | EC2 등 AWS 위에서 도는 자체 Runner에 IAM Role 부여 | 별도 인증 설정이 거의 불필요 | Runner 운영 비용과 관리 부담 |
| 외부 시크릿 브로커 (Vault 등) | HashiCorp Vault 등에서 임시 Credential 발급 | 멀티 클라우드 자격증명 통합 관리 | 브로커 인프라가 추가로 필요 |
그런데 이 표를 자세히 보면 이상한 점이 하나 있다.
GitHub이 "나는 이 레포의 main 브랜치에서 실행 중인 워크플로다"라고 증명하는 방식은 사실 첫 번째 하나뿐이다.
- Access Key는 증명이 아니라 비밀 공유다. 키를 가진 쪽이 누구든 통과한다.
- Self-hosted Runner는 GitHub의 신원이 아니라 러너가 올라가 있는 AWS 인스턴스의 신원을 쓴다.
- Vault는 어떨까? Vault의 JWT auth method 설정을 보면 결국 GitHub의 OIDC 토큰을 검증하고 있다.
즉, GitHub Actions가 외부 시스템에 자기 정체를 증명하는 방법은 OIDC 하나로 수렴한다. AWS든 Vault든 GCP든 마찬가지다.
OIDC를 쓰려면 IAM에 OIDC Provider를 등록하고, 역할의 신뢰 정책에 이런 조건을 건다.
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
처음 이걸 붙였을 때 나는 문서를 복사해서 그대로 붙여넣었다. 동작은 잘 됐지만, aud, sub에 대한 이해가 없기 때문에 누군가가 "그래서 aud, sub가 뭔데요?"라고 물었다면 대답하지 못했을 것이다.
문제는 이게 안 될 때 드러난다. 돌아오는 에러는 대개 이 한 줄이다.
Not authorized to perform sts:AssumeRoleWithWebIdentity
무엇이 틀렸는지는 알려주지 않는다. 따라서 원리를 모르면 어디서부터 잘못된 것인지 알 수 없기 때문에 해결하는데 시간이 오래 걸릴 것이다.
aud와 sub는 AWS가 만든 이름이 아니다
한참 헤매다 알게 된 사실이 있다. 저 클레임 이름들은 AWS의 발명품이 아니었다. OpenID Connect(OIDC)라는 표준 프로토콜 에 정의된 것이고, 흥미롭게도 전혀 다른 곳에서도 똑같이 등장한다.
바로 "구글로 로그인" 버튼을 누를 때다. 다음과 같이 터미널에 한 줄만 쳐서 확인해보면 된다.
curl -s https://accounts.google.com/.well-known/openid-configuration | jq
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "email", "profile"]
}
주소를 다시 볼 필요가 있다. oauth-configuration이 아니라 openid-configuration 이다.
흔히 "구글 로그인은 OAuth 2.0으로 구현했다"고들 말하지만, 정작 구글은 자기 자신을 OpenID Connect 공급자로 소개하고 있다.
왜 OAuth 2.0만으로는 로그인이 안 되는가
많은 서비스가 이런 식으로 소셜 로그인을 구현한다.
- 사용자를 구글 로그인 화면으로 보낸다
- 돌아온 인가 코드로 액세스 토큰을 받는다
- 그 액세스 토큰으로 프로필 API를 호출한다
- 이메일이 나온다 → "아, 이 사람이구나" → 세션 발급
하지만 여기엔 구멍이 있다.
OAuth 2.0에서 액세스 토큰은 이런 의미다.
"이 토큰을 들고 온 쪽은 이 리소스에 접근해도 된다."
결정적으로, 이 토큰은 원래 누구에게 발급된 것이라는걸 말하지 않는다.
애초에 OAuth 2.0 표준은 액세스 토큰의 포맷조차 규정하지 않는다.
그냥 불투명한 문자열이어도 표준에 부합한다. 안을 열어볼 수도 없고, 열어봐도 발급 대상이 적혀 있지 않다.
즉 액세스 토큰은 열쇠다. 주운 사람도 쓸 수 있다.
따라서, 공격자는 다음과 같이 공격할 수 있다.
① 공격자가 자기 앱(evil.com)을 구글에 등록해둔다
② 피해자가 evil.com에서 "구글로 로그인"을 누른다
→ evil.com이 피해자의 액세스 토큰을 손에 넣는다
③ 공격자가 그 토큰을 훔쳐 우리 서비스(myapp.com)에 접근한다
④ myapp.com은 그 토큰으로 구글 프로필 API를 호출한다
→ 피해자의 이메일이 정상적으로 반환된다
⑤ myapp.com: "피해자님 환영합니다" → 세션 발급
공격자가 피해자 계정으로 로그인했다.
그런데 myapp.com 입장에서는 뭘 잘못한 걸까? 구글에게 물어봤고, 구글이 피해자 이메일을 줬다. 아무 문제 없어 보인다.
문제는 ④에서 "이 토큰이 원래 우리를 위해 발급된 게 맞나?" 를 물어볼 수단이 없다는 것이다.
OIDC — OAuth 2.0에 신분증을 얹다
OIDC는 OAuth 2.0을 대체하는 게 아니다.
그 위에 얇게 얹힌 인증 계층이다. 인가 코드 플로우도, 토큰 엔드포인트도 전부 OAuth 2.0 그대로다.
달라지는 건 딱 하나, 인가 요청에 scope=openid를 넣는 것이다.
# OAuth 2.0
GET /authorize?response\_type=code&client\_id=...&scope=profile
# OIDC
GET /authorize?response\_type=code&client\_id=...&scope=openid profile
이 한 단어가 스위치다. openid가 붙으면 IdP는 토큰 응답에 ID Token을 하나 더 끼워 준다.
{
"access\_token": "eyJhbGc...",
"id\_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
"token\_type": "Bearer",
"expires\_in": 300
}
ID Token
ID Token은 JWT 형식이다. 점(.)으로 3등분되어 있다.
eyJhbGciOiJSUzI1NiIs... . eyJpc3MiOiJodHRwOi8v... . NHVaYe26MbtOYhSK...
└──── Header ────┘ └──── Payload ────┘ └── Signature ──┘
Header — 어떻게 서명했는지
{ "alg": "RS256", "kid": "abc123" }
Payload — 신분증의 내용
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"aud": "myapp-client-id",
"exp": 1735689600,
"iat": 1735686000,
"nonce": "n-0S6_WzA2Mj",
"email": "user@example.com"
}
| 클레임 | 뜻 | 왜 필요한가 |
|---|---|---|
iss |
누가 발급했나 (Issuer) | 내가 신뢰하는 IdP가 맞는지 |
sub |
이 사람은 누구인가 (Subject) | 사용자 고유 ID. 이메일보다 안정적 |
aud |
누구를 위한 토큰인가 (Audience) | 내 client_id가 맞는지 |
exp |
만료 시각 | 오래된 토큰 재사용 차단 |
nonce |
요청할 때 내가 넣은 난수 | 토큰 재생(replay) 차단 |
Signature — IdP의 개인키로 만든 서명
이제 2번 섹션의 공격 시나리오를 다시 떠올려보자. 공격자가 evil.com에서 피해자의 토큰을 얻어내 myapp.com에 제시하는 상황이었다.
ID Token으로 바꿔 놓고 같은 공격을 시도해본다고 가정해보자.
evil.com이 발급받은 ID Token을 열어보면, aud에는 evil-client-id가 적혀 있다. 구글이 evil.com을 위해 발급한 토큰이기 때문이다.
공격자가 이걸 myapp.com에 제출한다고 가정한다면 myapp.com은 aud가 내 client_id와 같은지에 대해서 살펴볼 것이다.
이렇게 된다면, 공격자가 발급받은 ID Token으로 다른 서비스에 제시해도 무용지물인 상황이 된다.
OIDC 전체 흐름 따라가기

1. 로그인 버튼 클릭 - 사용자가 내 서비스에서 "구글로 로그인"을 누른다. 아직 아무 일도 일어나지 않았다.
2. 인가 요청으로 리다이렉트 - 내 서비스는 사용자를 IdP의 인가 엔드포인트로 보낸다. 이때 쿼리스트링에 네 가지를 실어 보낸다. client_id(나는 누구인가), scope=openid(ID Token을 달라), state(CSRF 방어용 난수), nonce(재생 공격 방어용 난수). state와 nonce는 내가 만들어서 기억해둔다.
3. 인가 요청 + 로그인 · 동의 - 브라우저가 IdP로 이동한다. 사용자는 IdP의 화면에서 아이디·비밀번호를 입력하고, "이 앱에 프로필 정보를 제공하시겠습니까?"에 동의한다. 비밀번호는 IdP만 본다. 내 서비스는 절대 알 수 없다.
4. code + state 반환 IdP는 사용자를 다시 내 서비스의 redirect_uri로 돌려보낸다. 이때 주소에 인가 코드(code) 와 아까 그 state가 붙어 온다. 아직 토큰이 아니다. 토큰으로 바꿀 수 있는 교환권일 뿐이다.
5. code 전달 + state 검증 브라우저가 그 주소로 접속하면서 내 서비스에 code가 전달된다. 여기서 내 서비스는 2에서 보냈던 state와 돌아온 state가 같은지 확인한다. 다르면 내가 시작한 요청이 아니라는 뜻이므로 즉시 중단한다. (CSRF 방어)
6. 토큰 교환 요청 - 내 서비스가 IdP의 토큰 엔드포인트로 code와 client_secret을 함께 보낸다. client_secret은 내 서버에만 있다. 그래서 4에서 code를 훔쳐본 공격자가 있더라도 토큰으로 바꿀 수 없다.
7. 토큰 발급 - IdP가 access_token과 함께 id_token 을 내려준다. ②에서 scope=openid를 넣었기 때문이다.
8. 공개키 조회 - 받은 ID Token의 Header에서 kid를 꺼내, IdP의 JWKS 엔드포인트에 공개키를 요청한다. (JWKS 주소는 discovery 문서의 jwks_uri에 적혀 있다.)
9. 공개키 수신 - kid가 일치하는 공개키를 받아온다.
10. ID Token 검증 - 이 흐름의 핵심 받은 공개키로 다섯 가지를 확인한다.
| 서명 | 공개키로 검증 통과하는가 (위조되지 않았는가) |
| iss | 내가 신뢰하는 IdP가 발급한 게 맞는가 |
| aud | 내 client_id가 맞는가 |
| exp | 아직 만료되지 않았는가 |
| nonce | ②에서 내가 보낸 그 값이 맞는가 |
다섯 개 중 하나라도 틀리면 거부한다.
11. 세션 발급 - 전부 통과했다면 이제 사용자가 누구인지 확신할 수 있다. ID Token의 sub를 내 DB의 사용자와 매칭하고, 세션 쿠키를 발급한다.
직접 해보기 - Keyclack으로 IdP 운영하기
구글 로그인으로 실습을 진행해도 되지만, 로컬에 IdP를 직접 띄운다면 내부 통신 메시지를 마음대로 들여다볼 수 있다. Keycloak이면 컨테이너 하나로 준비를 할 수 있다.
1. IdP 띄우기

위 명령어로 keycloak 이미지를 받아와 컨테이너를 띄워준다.
2. Realm 만들기

격리된 사용자, 클라이언트 공간인 Realm을 만들어준다. 기본 master realm은 건들지않고 새로 demo realm을 만들어준다.
3. Client 등록
Client demo-client를 생성해준다.

반드시 Client authetication을 On으로 설정해줘야한다.

그 후, Valid redirect URIs를 존재하지 않는 localhost:3000 포트로 보내도록 한다.
이렇게 설정하는 이유는 주소창에 code 값을 확인하는 것이 목적이기에 상관없다.

save 후에 Credentials 란에서 Client Secret 값을 복사해준다.
그 후 로그인할 사용자를 만든다.
4. 로그인할 사용자 만들기
관리자 계정으로는 로그인할 수 없으므로, demo realm에 속한 사용자가 따로 필요하다.

alice 이름으로 생성해준다.

생성 후 Credentials 탭에서 Set Password로 패스워드를 등록해준다.
이때 Temporary를 On으로 해둔다면 첫 로그인 때 비밀번호 변경 화면이 뜨기 때문에 테스트를 위해서 꺼둔다.
5. 검증

1번 섹션에서 구글에 날렸던 것과 똑같은 주소, 똑같은 형태의 응답이다. 도메인이 localhost:8080으로 바뀌었을 뿐, 구조는 동일하다.
이제 이 응답에 적힌 주소들을 그대로 따라가면 된다.
6. 인가 요청 보내기
http://localhost:8080/realms/demo/protocol/openid-connect/auth?client_id=demo-client&response_type=code&scope=openid%20profile%20email&redirect_uri=http://localhost:3000/callback&state=xyz123&nonce=abc456
위의 주소를 그대로 브라우저 주소창에 복사해서 접속한다.
해당 주소의 파라미터를 하나씩 보면 다음과 같다.
- client_id - Keycloak에 등록한 그 이름이다.
- response_type=code - 인가 코드를 달라. Authorization Code Flow를 쓰겠다는 선언이다.
- scope=openid - openid가 없으면 ID Token은 발급되지 않는다.
- redirect_uri - Client 설정의 값과 한 글자도 다르면 안 된다.
- state, nonce - 내가 만든 난수로 실제 서비스라면 요청마다 새로 생성해 세션에 저장하지만, 지금은 눈으로 확인하기 위해 고정값을 쓴다.

위 주소로 들어가면 다음과 같이 로그인 화면이 뜬다. 아까 만들었던 유저의 아이디와 비밀번호를 통해 로그인을 시도하자.

그러면 다음과 같이 존재하지 않는 localhost:3000으로 리다이렉트가 되고, 그 주소창에는 code 값이 있는걸 확인할 수 있다.
7. 토큰 교환
CODE="<주소창에서 복사한 code>"
SECRET="<Credentials 탭의 client secret>"
curl -s -X POST http://localhost:8080/realms/demo/protocol/openid-connect/token \
-d grant_type=authorization_code \
-d client_id=demo-client \
-d client_secret=$SECRET \
-d code=$CODE \
-d redirect_uri=http://localhost:3000/callback | jq
위 명령어를 터미널에서 실행한다.

명령어를 실행하면 다음과 같이 토큰 응답이 된 것을 확인할 수 있다. 여기서 응답에 id_token이 들어있는데, 이 값을 통째로 복사해서 그대로 https://jwt.io 에 붙여넣어서 확인해보자.
JSON Web Tokens - jwt.io
JSON Web Token (JWT) is a compact URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is digitally signed using JSON Web Signature (JWS).
www.jwt.io

payload에 다음과 같이 3번 섹션에서 배웠던 iss, aud, sub과 같은 이름들이 모두 들어있는 것을 확인할 수 있다.
8. 서명 검증하기

먼저 Header에서 kid 값을 확인한다.
그 후, JWKS 엔드포인트를 호출한다.
curl -s http://localhost:8080/realms/demo/protocol/openid-connect/certs | jq

keys 배열에서 Header의 kid와 같은 값을 가진 키를 찾는다. 여기서 x5c에 해당하는 값이 토큰을 검증할 공개키이다.
-----BEGIN CERTIFICATE-----
MIICmzCCAYMCBgGT... (x5c 값 그대로)
-----END CERTIFICATE-----
jwt.io 에서 해당 공개키를 넣어보면 Valid Public key 가 뜨는 것을 확인할 수 있다.

로그인 화면이 없다면?
여기까지가 사람이 로그인하는 OIDC 과정이었다. 그러면 마지막 질문이 남았다.
OIDC에서 사람을 빼면 뭐가 남을까?
로그인 화면도, 동의 화면도, 브라우저 리다이렉트도 전부 지워보자. 그렇다면 남는 건 이것 뿐이다.
서명된 JWT 하나와 그걸 검증하는 쪽
그게 워크로드 페더레이션이다.
1번 섹션 맨 위에서 가져왔던 JSON을 다시 꺼내보자. 이제는 읽을 수 있다.
{
"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",
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}
- Principal -> iss 검증. 누가 발급한 토큰인가
- Condition의 aud -> 이 토큰이 STS를 위한 것인가
- Condition의 sub -> 어떤 레포의 어떤 브랜치인가
IAM 신뢰 정책은 결국 ID Token 검증 규칙을 IAM 문법으로 옮겨 적은 것이다. 서명 검증은 STS가 알아서 처리하고, 우리는 iss/aub/sub만 선언하면 된다.
OIDC 페더레이션이 안 될 때 확인할 사항
1. 워크플로에 토큰 발급 권한이 있는가
permissions:
id-token: write
contents: read
이게 없으면 Github이 애초에 ID Token을 발급하지 않는다.
2. IAM에 OIDC Provider가 등록되어 있는가
token.actions.githubusercontent.com이 Identity providers에 있어야 한다. 없으면 iss 검증 단계에서 막힌다.
3. aud가 신뢰 정책의 값과 같은가
configure-aws-credentials 액션의 기본값은 sts.amazonaws.com 이다. 액션 설정에서 audience를 바꿨다면 신뢰 정책도 같이 바꿔야한다.
4. sub의 형태가 정확한가
실행 맥락에 따라 형태가 다르다.
- 브랜치: repo:my-org/my-repo:ref:refs/heads/main
- 태그: repo:my-org/my-repo:ref:refs/tags/v1.0.0
- 환경: repo:my-org/my-repo:environment:production
- PR: repo:my-org/my-repo:pull_request
5. 역할 ARN이 맞는가
role-to-assume에 넣은 ARN의 계정 번호와 역할 이름을 다시 본다.
마치며
"구글로 로그인"과 "GitHub Actions가 AWS에 접근하는 것"은 같은 프로토콜이다.
- 액세스 토큰은 열쇠다. 주운 사람도 쓸 수 있다.
- ID Token은 신분증이다. 누가 발급했고(iss), 누구를 위한 것이며(aud), 누구인지(sub) 적혀 있고, 위조할 수 없다.
- 그 신분증을 사람이 들면 소셜 로그인, 워크플로가 들면 워크로드 페더레이션이다.
참고 문서
OpenID Connect - GitHub Docs
GitHub Actions workflows are often designed to access a cloud provider (such as AWS, Azure, GCP, HashiCorp Vault, and others) in order to deploy software or use the cloud's services. Before the workflow can access these resources, it will supply credential
docs.github.com
Configuring OpenID Connect in Amazon Web Services - GitHub Docs
Use OpenID Connect within your workflows to authenticate with Amazon Web Services.
docs.github.com
Secure GitHub Actions secrets with HashiCorp Vault | Well-Architected Framework | HashiCorp Developer
Learn how to secure GitHub Actions CI/CD secrets with HashiCorp Vault using JWT/OIDC, self-hosted runner auth patterns, and workflow-scoped Vault access.
developer.hashicorp.com
OpenID Connect | Sign in with Google | Google for Developers
Google uses AI technology to translate content into your preferred language. AI translations can contain errors. 의견 보내기 OpenID Connect 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요.
developers.google.com
Final: OpenID Connect Discovery 1.0 incorporating errata set 2
OpenID Connect 1.0 is a simple identity layer on top of the OAuth 2.0 protocol. It enables Clients to verify the identity of the End-User based on the authentication performed by an Authorization Server, as well as to obtain basic profile information about
openid.net
RFC 6749: The OAuth 2.0 Authorization Framework
The OAuth 2.0 authorization framework enables a third-party application to obtain limited access to an HTTP service, either on behalf of a resource owner by orchestrating an approval interaction between the resource owner and the HTTP service, or by allowi
datatracker.ietf.org
End User Authentication with OAuth 2.0 — OAuth
User Authentication with OAuth 2.0 The OAuth 2.0 specification defines a delegation protocol that is useful for conveying authorization decisions across a network of web-enabled applications and APIs. OAuth is used in a wide variety of applications, includ
oauth.net