OIDC 인증 구조: Authorization Code + PKCE, id_token 검증, GitHub Actions → AWS 워크로드 인증
OAuth 2.0만으로 로그인을 구현하면 생기는 문제, OIDC의 Authorization Code + PKCE 흐름, id_token과 JWKS의 구조, RP가 해야 하는 검증 순서, 같은 구조가 GitHub Actions → AWS·EKS IRSA 같은 워크로드 인증에서 쓰이는 방식을 정리했다.
OpenID Connect(OIDC)는 소셜 로그인·사내 SSO 같은 사람 로그인과, GitHub Actions에서 Access Key 없이 AWS에 접근하는 워크로드 인증에 같이 쓰인다. 이 글은 두 용례를 같은 구조로 정리한다. 순서는 OAuth 2.0만으로 로그인을 구현할 때의 문제, 로그인 흐름, id_token 구조, 검증 순서, 워크로드 인증, 자주 나오는 실수다.
OAuth 2.0과 OIDC의 차이
OAuth 2.0은 인가(authorization) 프로토콜이다. “이 앱이 내 Google Drive 파일을 읽어도 된다”를 사용자가 승인하면 앱은 access_token을 받고, 이 토큰으로 API를 호출한다.
OAuth 표준은 access token의 형식을 정하지 않는다. JWT일 수도 있고 opaque 문자열일 수도 있다. 토큰을 가진 쪽이 누구인지도 표준이 알려 주지 않는다. 그래서 access token으로 /me 같은 프로필 API를 호출해 로그인을 구현하는 방식이 많이 쓰였다.
이 방식은 토큰 치환 공격에 취약하다. 앱 A용으로 발급된 access token을 공격자가 앱 B의 로그인 엔드포인트에 넣으면, 앱 B는 /me를 호출해 정상적인 사용자 정보를 받는다. 앱 B는 그 토큰이 자기에게 발급된 것인지 확인할 방법이 없다.
OIDC는 OAuth 2.0 위에 id_token을 추가했다. id_token은 IdP가 서명한 JWT이고, 사용자 식별자(sub), 발급자(iss), 수신 앱(aud), 인증 시각을 담는다. 앱은 서명과 aud를 확인해서 위의 치환 공격을 막는다.
등장인물
| 역할 | OIDC 용어 | 예 |
|---|---|---|
| 신원을 보증하는 쪽 | OP (OpenID Provider), 흔히 IdP | Google, Okta, Keycloak, Cognito, GitHub Actions |
| 신원을 받아 쓰는 쪽 | RP (Relying Party), OAuth의 Client | 웹앱, AWS STS |
| 인증 대상 | End-User | 브라우저 사용자, CI job 같은 워크로드 |
워크로드 인증에서는 GitHub Actions가 OP, AWS STS가 RP가 된다. 사람은 등장하지 않는다.
용례
사람 로그인:
- 소셜 로그인, Okta·Keycloak 기반 사내 SSO가 여기에 속한다.
- 앱은 비밀번호를 저장하지 않는다. IdP에 인증을 맡기고, id_token을 검증한 뒤 자체 세션을 발급한다.
- 여러 서비스가 IdP 하나를 공유하면 SSO가 된다.
워크로드 인증(Workload Identity Federation):
- GitHub Actions → AWS: job이 GitHub에서 OIDC 토큰을 받고, STS
AssumeRoleWithWebIdentity로 임시 자격 증명과 교환한다. GitHub Secret에 장기 Access Key를 둘 필요가 없다. - EKS IRSA: 클러스터가 OP이고 ServiceAccount 토큰이 OIDC 토큰이다. Pod는 이 토큰으로 IAM Role을 assume한다.
- GCP Workload Identity Federation, Azure Federated Credential, Vault JWT auth,
kubectl의 OIDC 사용자 인증도 같은 구조다.
API 게이트웨이의 JWT 검증은 OAuth access token 검증이지만, OIDC의 discovery와 JWKS를 그대로 사용한다.
Authorization Code Flow + PKCE
웹, 모바일, SPA 모두 이 흐름을 쓴다. 토큰을 URL fragment로 돌려주던 Implicit Flow는 OAuth 2.0 Security Best Current Practice(RFC 9700)에서 사용하지 말라고 권고한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Browser RP(앱 서버) OP(IdP)
│ ① /login │ │
│─────────────────▶│ state, nonce, code_verifier 생성 │
│ │ (서버 세션 또는 쿠키에 저장) │
│◀─ 302 ───────────│ │
│ ② GET /authorize?response_type=code │
│ &client_id=..&redirect_uri=.. │
│ &scope=openid profile email │
│ &state=..&nonce=.. │
│ &code_challenge=BASE64URL(SHA256(verifier)) │
│ &code_challenge_method=S256 │
│───────────────────────────────────────────────────▶│
│ ③ 사용자 로그인 + MFA + 동의 │
│◀─ 302 redirect_uri?code=abc&state=.. ──────────────│
│─────────────────▶│ │
│ │ ④ state 일치 확인 │
│ │ POST /token │
│ │ grant_type=authorization_code │
│ │ code=abc, code_verifier=.. │
│ │ + client 인증 │
│ │────────────────────────────────▶│
│ │◀─ id_token, access_token, │
│ │ refresh_token, expires_in │
│ │ ⑤ id_token 검증 → 자체 세션 발급 │
│◀─ Set-Cookie ────│ │
scope에 openid가 있어야 OIDC 요청으로 처리된다. 없으면 일반 OAuth 요청이다.
요청 파라미터별 역할:
state: CSRF 방어. 공격자가 자기 계정으로 받은 code를 피해자 브라우저의 콜백 URL로 보내면, 피해자는 공격자 계정으로 로그인된다(login CSRF). 콜백에서 state가 세션 값과 다르면 거절한다.nonce: id_token 재사용 방어. 요청 때 보낸 값이 id_token의nonce클레임으로 돌아와야 한다.- PKCE: code 탈취 방어. RP가 무작위
code_verifier를 만들고 그 해시만 authorize 요청에 보낸다. code를 가로채도 원본 verifier가 없으면/token에서 교환할 수 없다. client secret을 안전하게 보관할 수 없는 모바일 앱과 SPA에 필요하고, confidential client에도 적용하는 것이 권장된다. redirect_uri: OP에 등록된 값과 문자열이 정확히 일치해야 한다. 와일드카드나 prefix 매칭을 허용하면 open redirect를 거쳐 code가 유출될 수 있다.
토큰은 ④의 백채널 호출로만 전달된다. 브라우저 주소창에는 수명이 짧고 한 번만 쓸 수 있는 code만 지나간다.
④의 client 인증은 client_secret을 보내는 방식이 일반적이다. private_key_jwt를 쓰면 RP가 자기 개인키로 서명한 JWT로 인증하므로 OP와 공유하는 비밀이 없다.
id_token 구조
id_token은 JWT다. 세 부분이 각각 base64url로 인코딩되어 점으로 이어진다.
1
base64url(header) . base64url(payload) . base64url(signature)
header는 서명 알고리즘과 키 ID를 담는다.
1
{ "alg": "RS256", "kid": "2026-10-key-a", "typ": "JWT" }
payload는 클레임을 담는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"iss": "https://accounts.google.com",
"sub": "110248495921238986420",
"aud": "my-client-id",
"exp": 1792300000,
"iat": 1792296400,
"nonce": "n-0S6_WzA2Mj",
"auth_time": 1792296390,
"amr": ["pwd", "mfa"],
"azp": "my-client-id",
"email": "a@example.com",
"email_verified": true
}
| 클레임 | 의미 |
|---|---|
iss | 발급자. OP의 issuer URL |
sub | 사용자 식별자. 해당 issuer 안에서 유일하고 바뀌지 않는다 |
aud | 토큰을 받을 RP의 client_id |
exp, iat | 만료 시각, 발급 시각 |
nonce | authorize 요청 때 RP가 보낸 값 |
auth_time | 사용자가 실제로 인증한 시각. 재인증 강제에 쓴다 |
amr, acr | 인증 수단, 인증 수준. MFA 여부 판단에 쓴다 |
signature는 OP가 header.payload를 개인키로 서명한 값이다. RP는 OP의 공개키로 검증한다.
Discovery와 JWKS
OP의 설정은 고정 경로에서 조회한다.
1
GET {issuer}/.well-known/openid-configuration
1
2
3
4
5
6
7
8
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://.../auth",
"token_endpoint": "https://.../token",
"userinfo_endpoint": "https://.../userinfo",
"jwks_uri": "https://.../certs",
"id_token_signing_alg_values_supported": ["RS256"]
}
jwks_uri는 공개키 목록(JWK Set)을 반환한다.
1
2
3
4
5
6
{
"keys": [
{ "kid": "2026-10-key-a", "kty": "RSA", "alg": "RS256", "n": "...", "e": "AQAB" },
{ "kid": "2026-09-key-b", "kty": "RSA", "alg": "RS256", "n": "...", "e": "AQAB" }
]
}
RP는 id_token header의 kid로 맞는 키를 고른다. 키가 여러 개인 이유는 키 회전이다. OP는 새 키를 JWKS에 먼저 게시하고, 그다음 새 키로 서명을 시작하고, 옛 키로 서명한 토큰이 모두 만료된 뒤 옛 키를 내린다.
RP의 JWKS 캐시 구현 요건:
- 캐시에 없는
kid가 들어오면 JWKS를 다시 조회한다. 이 처리가 없으면 OP가 키를 회전할 때 로그인이 일제히 실패한다. - 재조회에는 최소 간격을 둔다. 간격이 없으면 임의의
kid를 넣은 토큰만으로 JWKS 엔드포인트에 요청을 반복시킬 수 있다.
AWS IAM OIDC Provider의 thumbprint는 STS가 jwks_uri에 접속할 때 TLS 서버 인증서를 검증하는 데 쓰던 값이다. GitHub Actions는 2023년 7월부터, 그 밖의 OIDC IdP는 2024년 7월부터 IAM이 신뢰 루트 CA로 인증서를 검증하므로 thumbprint를 쓰지 않는다. 자체 서명 인증서나 흔하지 않은 루트 CA를 쓰는 IdP에는 여전히 thumbprint가 쓰인다.
RP의 id_token 검증 순서
alg가 허용 목록에 있는지 확인한다.none은 거절한다. RS256을 기대하는 곳에서 HS256을 받아 주면, 공격자가 공개키를 HMAC 비밀키로 써서 서명한 토큰이 통과한다(알고리즘 혼동 공격).kid로 JWKS에서 공개키를 찾아 서명을 검증한다.iss가 discovery 문서의issuer와 정확히 같은지 확인한다.aud에 자기 client_id가 있는지 확인한다.aud가 여러 값이면azp도 확인한다.exp,iat을 확인한다. 서버 간 시계 차이로 1~2분의 여유를 둔다.nonce가 세션에 저장한 값과 같은지 확인하고, 확인한 nonce는 폐기한다.- 정책에 따라
auth_time(최근 재인증 여부),amr·acr(MFA 여부)을 확인한다. - 사용자 계정은
iss와sub의 조합으로 식별한다.
서명 검증 통과는 해당 IdP가 발급했다는 뜻이고, 자기 앱용으로 발급했다는 뜻은 아니다. 같은 IdP에 등록된 다른 앱의 id_token도 서명은 유효하므로 4번
aud확인을 생략하면 안 된다.
8번에서 email을 계정 키로 쓰면 안 된다. email은 사용자가 바꿀 수 있고, IdP에 따라 소유 확인을 거치지 않은 값이 올 수 있다. 여러 IdP를 연동한 서비스에서 email로 계정을 병합하면, 공격자가 다른 IdP에 피해자 email로 가입하는 것만으로 피해자 계정에 접근할 수 있다.
검증 로직은 라이브러리에 맡긴다. Node
openid-client·jose, PythonAuthlib, Spring Security OAuth2 Client, Gogo-oidc가 위 순서를 구현한다. 위 목록은 라이브러리 설정 점검용으로 쓰고, 허용alg와aud설정은 직접 확인한다.
토큰 세 종류
| 토큰 | 받는 쪽 | 용도 | 주의 |
|---|---|---|---|
id_token | RP | 로그인 사용자 확인 | API 호출용 Bearer로 쓰지 않는다 |
access_token | Resource Server(API) | API 인가 | 형식이 정해져 있지 않다. RP가 내용을 해석한다고 전제하지 않는다 |
refresh_token | OP | access token 재발급 | 서버에만 보관하고 rotation과 재사용 탐지를 켠다 |
id_token은 JWT라서 API 게이트웨이의 서명 검증을 통과한다. 그래서 API 호출에 잘못 쓰이는 경우가 있다. id_token의 aud는 RP의 client_id이고 API가 아니다. 리소스 서버는 자기에게 발급된 access token만 받아야 한다.
/userinfo는 access token으로 프로필 클레임을 조회하는 엔드포인트다. id_token 크기를 줄이고 싶을 때 쓴다.
SSO 환경에는 RP 세션, OP 세션, 같은 OP를 쓰는 다른 RP의 세션이 따로 존재한다. OIDC는 로그아웃 방식을 세 가지 정의한다.
- RP-Initiated Logout: RP가
end_session_endpoint로 리다이렉트해 OP 세션을 끝낸다. - Back-Channel Logout: OP가 각 RP에
logout_token을 POST한다. - Front-Channel Logout: OP가 iframe으로 각 RP의 로그아웃 URL을 호출한다.
RP 세션만 지우면 OP 세션이 남아 있으므로, 다음 로그인 시도에서 사용자는 자격 증명 입력 없이 다시 로그인된다.
GitHub Actions → AWS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
GitHub Actions job (permissions: id-token: write)
│ ① ACTIONS_ID_TOKEN_REQUEST_URL 호출 (audience=sts.amazonaws.com)
▼
GitHub OIDC OP (iss = https://token.actions.githubusercontent.com)
│ ② JWT 발급
│ sub = repo:my-org/my-repo:ref:refs/heads/main
│ aud = sts.amazonaws.com
│ + repository, ref, environment, job_workflow_ref ...
▼
AWS STS AssumeRoleWithWebIdentity(RoleArn, WebIdentityToken=JWT)
│ ③ 등록된 OIDC Provider의 JWKS로 서명 검증
│ ④ Role trust policy의 조건 평가
▼
임시 자격 증명 (기본 1시간)
aws-actions/configure-aws-credentials가 ①~③을 수행한다. GitHub이 OP, STS가 RP, job이 End-User다. STS가 서명과 iss를 검증하고, 나머지 조건은 Role의 trust policy가 판단한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"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",
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}
GitHub Actions의 OIDC issuer는 GitHub 전체에서 하나다. 자기 repo의 job이 받은 토큰과 다른 사람의 public repo에서 받은 토큰은 서명이 똑같이 유효하다.
trust policy에서
sub조건을 빼거나repo:*처럼 넓게 잡으면, GitHub에서 workflow를 실행할 수 있는 모든 계정이 그 Role을 assume할 수 있다.
sub 형식은 트리거에 따라 다르다.
- 브랜치 push:
repo:org/repo:ref:refs/heads/main - environment를 지정한 job:
repo:org/repo:environment:production - PR:
repo:org/repo:pull_request
배포 Role은 environment나 특정 브랜치로 제한한다. pull_request에 배포 권한을 주면 PR 작성자가 workflow 파일을 수정해 그 권한을 쓸 수 있다.
EKS IRSA도 구조가 같다. OP는 클러스터의 OIDC issuer이고, sub는 system:serviceaccount:<namespace>:<serviceaccount>다. trust policy의 sub를 와일드카드로 두면 같은 클러스터의 다른 네임스페이스 Pod가 그 Role을 assume할 수 있다.
자주 나오는 실수
로그인:
aud검증 누락: 같은 IdP에 등록된 다른 앱의 토큰으로 로그인된다.alg미고정:none또는 HS256 혼동 공격이 가능해진다.email을 계정 키로 사용: IdP를 여러 개 연동하면 계정 탈취 경로가 된다.- 미지의
kid재조회 로직 누락: OP 키 회전 시 로그인이 실패한다. - id_token을 API Bearer로 사용: 토큰 수신자 구분이 깨진다.
- SPA에 refresh token 저장: XSS로 장기 자격 증명이 유출된다. 토큰을 서버(BFF, Backend for Frontend)에 두고 브라우저에는 HttpOnly 세션 쿠키만 주는 구성이 권장된다.
워크로드:
- trust policy의
sub조건 누락 또는 과도한 와일드카드: 외부 repo나 다른 네임스페이스에 Role이 열린다. pull_request트리거에 쓰기 권한 Role 부여: PR 작성자가 그 권한을 쓸 수 있다.aud조건 누락: 다른 용도로 발급된 토큰이 재사용될 수 있다.
관련 표준
- SAML: XML 서명 기반의 SSO 표준이다. 엔터프라이즈 환경에서 여전히 쓰인다. OIDC는 JSON과 JWT 기반이라 모바일, API, 워크로드 환경에 맞는다.
- OAuth 2.1: IETF OAuth WG의 Internet-Draft다(2025년 10월
draft-ietf-oauth-v2-1-14기준, RFC 아님). PKCE 필수화, Implicit grant와 Resource Owner Password Credentials grant 제거 등 기존 권장 사항을 하나의 문서로 묶는다. - DPoP(RFC 9449), mTLS-bound token(RFC 8705): 토큰을 클라이언트 키에 바인딩해, 유출된 토큰을 다른 클라이언트가 쓰지 못하게 한다.
- Token Exchange(RFC 8693): 받은 토큰을 다른 audience용 토큰으로 교환한다. 서비스 간 위임에 쓴다.