- Published on
OpenID Connect 딥다이브 — Authorization Code Flow부터 Discovery까지
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- OIDC의 레이어 구조 — OAuth 2.0 위에 쌓은 것
- 세 가지 토큰 — 역할을 절대 섞지 말 것
- Authorization Code Flow + PKCE — HTTP 수준 해부
- Discovery — 설정을 코드에 박지 않는 법
- JWKS와 키 회전
- UserInfo, 클레임, 스코프
- state와 nonce — 두 개의 다른 방패
- 토큰 검증 체크리스트 (실무용)
- 로그아웃 — OIDC의 어려운 부분
- OAuth 2.1 시대의 OIDC — 2026년 정합성 정리
- 트러블슈팅 노트
- 마치며
- 참고 자료
들어가며
2026년 현재, 새로 만드는 거의 모든 시스템의 로그인은 OpenID Connect(OIDC)로 수렴했습니다. 소셜 로그인도, 사내 SSO도, 모바일 앱도, CLI 도구의 로그인마저도 그 바닥에는 OIDC Authorization Code Flow가 흐릅니다. 그런데도 OIDC를 "라이브러리 설정 몇 줄"로만 아는 채 운영하다가, 토큰 검증 누락이나 nonce 미사용 같은 구멍으로 사고가 나는 사례가 끊이지 않습니다.
이 글은 OIDC를 프로토콜 수준에서 해부합니다. HTTP 요청/응답을 직접 읽고, 세 가지 토큰의 역할을 구분하고, Discovery와 JWKS의 동작을 이해하고, 마지막에 실무용 토큰 검증 체크리스트로 정리합니다. OAuth 2.1 드래프트가 사실상의 기준이 된 시점이므로, 모든 설명은 Code + PKCE를 전제로 합니다.
OIDC의 레이어 구조 — OAuth 2.0 위에 쌓은 것
OIDC는 OAuth 2.0을 대체하지 않습니다. OAuth 2.0의 흐름을 그대로 쓰면서, 그 위에 인증 레이어를 추가합니다.
+---------------------------------------------------------------+
| OIDC가 추가한 것 |
| - ID Token (서명된 JWT, 인증 결과의 표준 포맷) |
| - scope=openid 와 표준 클레임 (sub, email, name...) |
| - Discovery (/.well-known/openid-configuration) |
| - JWKS (서명 키 배포), UserInfo 엔드포인트 |
| - nonce, max_age, prompt, acr 등 인증 전용 파라미터 |
| - RP-Initiated / Back-Channel Logout |
+---------------------------------------------------------------+
| OAuth 2.0 (RFC 6749) 가 제공하는 것 |
| - authorize / token 엔드포인트와 grant 흐름 |
| - access token, refresh token |
| - client 등록과 redirect_uri 검증 |
+---------------------------------------------------------------+
| 기반: HTTP + TLS, JWT(RFC 7519), JWS/JWK |
+---------------------------------------------------------------+
용어도 OAuth 위에 얹혀 바뀝니다. OAuth의 client는 OIDC에서 RP(Relying Party), authorization server는 **OP(OpenID Provider)**가 됩니다.
세 가지 토큰 — 역할을 절대 섞지 말 것
| 항목 | ID Token | Access Token | Refresh Token |
|---|---|---|---|
| 받는 사람(audience) | RP (클라이언트) | Resource Server (API) | OP의 token 엔드포인트 |
| 목적 | "사용자가 인증되었다"는 증명 | API 호출 권한 | 새 토큰 재발급 |
| 포맷 | 반드시 JWT | 자유 (JWT 또는 opaque) | 자유 (보통 opaque) |
| 검증 주체 | RP가 직접 서명/클레임 검증 | API 서버가 검증 | OP만 해석 |
| 수명 | 짧게 (분 단위) | 짧게 (5~15분 권장) | 길게 (회전 필수) |
| 어디로 보내면 안 되나 | API에 보내지 말 것 | 로그인 증거로 쓰지 말 것 | RP 밖으로 절대 내보내지 말 것 |
가장 흔한 두 가지 오용:
- ID Token을 API에 Bearer로 보내는 것 — ID Token의 aud는 client_id이지 API가 아닙니다. API가 이를 받아주면 audience 검증을 포기한 것입니다.
- Access Token으로 로그인 처리 — access token에는 "누가 인증했는가"에 대한 수신자 보증이 없습니다. 로그인 판단은 ID Token으로만 합니다.
ID Token 내부 들여다보기
ID Token은 세 부분(header.payload.signature)으로 구성된 JWS입니다.
{
"alg": "RS256",
"typ": "JWT",
"kid": "rsa-key-2026-05"
}
{
"iss": "https://idp.corp.com/realms/prod",
"sub": "f9a8b7c6-1234-5678-90ab-cdef12345678",
"aud": "web-dashboard",
"exp": 1781258400,
"iat": 1781258100,
"auth_time": 1781258095,
"nonce": "n-0S6_WzA2Mj",
"acr": "urn:mace:incommon:iap:silver",
"azp": "web-dashboard",
"email": "yj.kim@corp.com",
"email_verified": true,
"name": "Youngju Kim",
"preferred_username": "yj.kim"
}
- iss: 발급자. Discovery 문서의 issuer와 문자열로 정확히 일치해야 합니다.
- sub: 사용자의 불변 식별자. 계정 매칭은 email이 아니라 iss+sub 조합으로 해야 합니다. 이메일은 바뀌고, 재사용되고, 검증되지 않았을 수 있습니다.
- aud / azp: 이 토큰의 수신자(내 client_id). 다른 앱의 토큰을 거부하는 근거입니다.
- exp / iat / auth_time: 만료, 발급, 실제 인증 시각. auth_time은 max_age 요구와 함께 재인증 정책에 쓰입니다.
- nonce: 인증 요청 때 보낸 값의 메아리. 리플레이 방어의 핵심입니다.
Authorization Code Flow + PKCE — HTTP 수준 해부
전체 시퀀스는 다음과 같습니다.
[브라우저] [RP 서버] [OP]
| | |
|-- 1. GET /login ----->| |
| | 2. state, nonce, |
| | code_verifier 생성 |
|<- 3. 302 OP /authorize 로 리다이렉트 ---------|
|-- 4. GET /authorize ------------------------->|
|<- 5. 로그인 UI (SSO 세션 있으면 생략) --------|
|-- 6. 인증 (passkey/MFA) --------------------->|
|<- 7. 302 redirect_uri?code=...&state=... -----|
|-- 8. GET /callback?code=...&state=... ->| |
| | 9. state 대조 |
| |-- 10. POST /token --->|
| | (code+verifier) |
| |<- 11. 토큰 응답 ------|
| | 12. id_token 검증, |
| | nonce 대조 |
|<- 13. 세션 쿠키 ------| |
1단계: 인증 요청 만들기
RP는 요청 전에 세 가지 임의 값을 만듭니다.
# state: CSRF 방어용 (세션에 저장)
state=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-43)
# nonce: ID Token 리플레이 방어용 (세션에 저장)
nonce=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-43)
# PKCE: code_verifier와 그 SHA-256 해시인 code_challenge
code_verifier=$(openssl rand -base64 64 | tr -d '=+/' | cut -c1-128)
code_challenge=$(printf '%s' "$code_verifier" \
| openssl dgst -sha256 -binary | openssl base64 -A \
| tr '+/' '-_' | tr -d '=')
그리고 브라우저를 authorization 엔드포인트로 보냅니다.
GET /realms/prod/protocol/openid-connect/auth
?response_type=code
&client_id=web-dashboard
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid%20profile%20email
&state=af0ifjsldkj
&nonce=n-0S6_WzA2Mj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 HTTP/1.1
Host: idp.corp.com
2단계: 콜백과 code 수신
인증이 끝나면 OP가 브라우저를 되돌려 보냅니다.
HTTP/1.1 302 Found
Location: https://app.example.com/callback
?code=SplxlOBeZQQYbYS6WxSbIA
&state=af0ifjsldkj
&iss=https%3A%2F%2Fidp.corp.com%2Frealms%2Fprod
RP가 즉시 할 일: 세션의 state와 돌아온 state가 일치하는지 비교합니다. 불일치하면 그 자리에서 중단합니다(CSRF 시도). iss 파라미터(RFC 9207)가 있다면 기대한 발급자인지도 확인해 mix-up 공격을 차단합니다.
3단계: code를 토큰으로 교환 (back-channel)
POST /realms/prod/protocol/openid-connect/token HTTP/1.1
Host: idp.corp.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic d2ViLWRhc2hib2FyZDpzM2NyM3Q=
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
OP는 code_verifier를 SHA-256 해시해 처음 받았던 code_challenge와 비교합니다. 코드가 탈취되어도 verifier 없이는 교환이 불가능합니다 — 이것이 PKCE(RFC 7636)입니다.
응답:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "eyJhbGciOiJIUzUxMiIs...",
"id_token": "eyJhbGciOiJSUzI1NiIs...",
"scope": "openid profile email"
}
RP는 id_token을 검증하고(아래 체크리스트), payload의 nonce가 세션에 저장한 값과 일치하는지 확인한 뒤에야 로그인 처리를 합니다.
confidential vs public 클라이언트
| 구분 | confidential | public |
|---|---|---|
| 예 | 서버 렌더링 웹 앱, BFF | SPA, 모바일, CLI |
| client_secret | 보유 (또는 private_key_jwt) | 없음 |
| token 요청 인증 | Basic 또는 mTLS/JWT | client_id만 |
| PKCE | OAuth 2.1에서 그래도 필수 | 필수 (유일한 방어선) |
OAuth 2.1 기준으로 PKCE는 클라이언트 유형과 무관하게 전부 적용합니다. SPA라면 토큰을 브라우저에 직접 들지 말고 BFF(Backend For Frontend) 패턴으로 confidential 클라이언트화하는 것이 2026년의 권장 구조입니다.
Discovery — 설정을 코드에 박지 않는 법
OP는 자신의 모든 설정을 표준 경로에 게시합니다.
curl -s https://idp.corp.com/realms/prod/.well-known/openid-configuration
{
"issuer": "https://idp.corp.com/realms/prod",
"authorization_endpoint": "https://idp.corp.com/realms/prod/protocol/openid-connect/auth",
"token_endpoint": "https://idp.corp.com/realms/prod/protocol/openid-connect/token",
"userinfo_endpoint": "https://idp.corp.com/realms/prod/protocol/openid-connect/userinfo",
"jwks_uri": "https://idp.corp.com/realms/prod/protocol/openid-connect/certs",
"end_session_endpoint": "https://idp.corp.com/realms/prod/protocol/openid-connect/logout",
"scopes_supported": ["openid", "profile", "email", "offline_access"],
"response_types_supported": ["code"],
"id_token_signing_alg_values_supported": ["RS256", "ES256", "EdDSA"],
"code_challenge_methods_supported": ["S256"]
}
RP 라이브러리는 issuer URL 하나만 설정하면 나머지를 여기서 읽어옵니다. 검증 규칙 하나가 중요합니다. 문서 안의 issuer 값이, 문서를 가져온 URL의 issuer 부분과 일치해야 합니다. 이 확인이 없으면 악성 OP가 남의 설정을 흉내 내는 공격이 가능합니다.
Keycloak 26.6 기준으로 EdDSA 서명도 지원되므로, 신규 구축이라면 RS256 외에 ES256/EdDSA 채택도 검토할 만합니다.
JWKS와 키 회전
jwks_uri는 ID Token 서명 검증에 쓸 공개키 목록(JWK Set)을 제공합니다.
{
"keys": [
{
"kid": "rsa-key-2026-05",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "0vx7agoebGcQSuuPiLJXZpt...",
"e": "AQAB"
},
{
"kid": "rsa-key-2026-06",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "zNf1a9pXkQpW3mLbV2c...",
"e": "AQAB"
}
]
}
검증 흐름: 토큰 header의 kid → JWKS에서 같은 kid의 키 선택 → 서명 검증. 키가 두 개 보이는 것이 정상입니다. 키 회전 중에는 새 키와 옛 키가 공존하기 때문입니다.
키 회전을 무중단으로 만드는 운영 수칙:
- OP: 새 키를 JWKS에 먼저 게시하고, 충분한 시간(캐시 TTL 이상)이 지난 뒤에 새 키로 서명을 시작하고, 옛 키로 서명된 토큰이 모두 만료된 뒤에 옛 키를 제거합니다.
- RP: JWKS를 캐시하되(매 요청 조회 금지), 모르는 kid를 만나면 한 번 재조회하는 로직을 넣습니다. 단, 재조회에 rate limit을 걸어 DoS를 방지합니다.
- kid 없는 토큰, JWKS에 없는 kid, alg 불일치는 전부 거부합니다.
UserInfo, 클레임, 스코프
스코프 → 클레임 매핑
scope는 "어떤 클레임 묶음을 원하는가"의 단위입니다.
| scope | 풀리는 표준 클레임 |
|---|---|
| openid | sub (OIDC 요청임을 표시, 필수) |
| profile | name, family_name, given_name, preferred_username, picture 등 |
| email, email_verified | |
| address | address |
| phone | phone_number, phone_number_verified |
| offline_access | refresh token 발급 요청 |
UserInfo 엔드포인트
클레임은 ID Token에 담겨 오거나, access token으로 UserInfo를 호출해 받을 수 있습니다.
curl -s https://idp.corp.com/realms/prod/protocol/openid-connect/userinfo \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."
{
"sub": "f9a8b7c6-1234-5678-90ab-cdef12345678",
"name": "Youngju Kim",
"email": "yj.kim@corp.com",
"email_verified": true,
"groups": ["sso-admins", "developers"]
}
주의: UserInfo 응답의 sub가 ID Token의 sub와 일치하는지 반드시 비교해야 합니다. 토큰 치환 공격을 걸러내는 마지막 안전망입니다. ID Token을 가볍게 유지하고 싶으면 클레임을 UserInfo로 미루고, 게이트웨이에서 한 번에 쓰고 싶으면 ID Token에 포함시키는 식으로 트레이드오프를 설계합니다.
state와 nonce — 두 개의 다른 방패
혼동하기 쉬운 두 파라미터의 역할을 명확히 구분합니다.
| 항목 | state | nonce |
|---|---|---|
| 방어 대상 | CSRF (콜백 위조) | ID Token 리플레이/주입 |
| 누가 만드나 | RP | RP |
| 어디서 돌아오나 | 콜백의 쿼리 파라미터 | ID Token의 클레임 |
| 언제 검증하나 | 콜백 수신 즉시 | ID Token 검증 시 |
| 없으면 생기는 일 | 공격자가 자기 code를 피해자 세션에 주입 (세션 고정) | 탈취된 ID Token의 재사용 |
전형적 공격 시나리오 하나를 보겠습니다. state가 없다면, 공격자는 자기 계정으로 로그인 절차를 진행하다 콜백 URL(자기 code 포함)을 빼내 피해자에게 클릭시킵니다. 피해자의 브라우저가 그 콜백을 밟으면 피해자 세션이 공격자 계정으로 로그인됩니다. 이후 피해자가 입력하는 데이터는 공격자 계정에 쌓입니다. state 대조 한 줄이 이 모든 것을 막습니다.
토큰 검증 체크리스트 (실무용)
ID Token 수신 시 (RP):
[ ] 1. token 엔드포인트의 TLS 응답에서 받았는가 (front-channel 수신 금지)
[ ] 2. JWS header의 alg가 허용 목록(RS256/ES256/EdDSA)에 있는가
- alg=none 거부, 대칭(HS256) 혼용 거부
[ ] 3. kid로 JWKS에서 키를 찾아 서명 검증
[ ] 4. iss == Discovery의 issuer (문자열 정확 일치)
[ ] 5. aud에 내 client_id 포함, 다중 audience면 azp == 내 client_id
[ ] 6. exp 미경과, iat 합리적 범위 (clock skew 60~120초 허용)
[ ] 7. nonce == 세션에 저장한 값 (사용 후 폐기)
[ ] 8. 재인증 요구가 있었다면 auth_time/acr 확인
[ ] 9. 계정 매칭은 iss+sub 조합으로 (email 매칭 금지)
Access Token 수신 시 (Resource Server):
[ ] 1. 서명 검증 (JWT인 경우) 또는 introspection (opaque인 경우)
[ ] 2. iss, exp 검증
[ ] 3. aud 또는 resource indicator가 "이 API"인지 검증
[ ] 4. scope가 요청된 작업을 허용하는지 확인
[ ] 5. 필요 시 토큰 발급 시각 기반의 폐기 정책 적용
이 중 4번(iss)과 5번(aud)을 생략한 구현이 현실에 놀랄 만큼 많습니다. "서명만 유효하면 통과"는 아무 OP의 아무 토큰이나 통과라는 뜻입니다.
로그아웃 — OIDC의 어려운 부분
OIDC는 세 가지 로그아웃 메커니즘을 표준화했습니다.
- RP-Initiated Logout: RP가 end_session_endpoint로 보내 OP 세션을 종료.
- Front-Channel Logout: OP가 각 RP의 로그아웃 URL을 iframe으로 로드. 서드파티 쿠키 차단으로 신뢰성이 낮아져 사양길.
- Back-Channel Logout: OP가 각 RP의 백엔드로 logout token(JWT)을 직접 POST. 2026년 시점의 권장안.
Back-channel logout을 받으려면 RP가 "sid(세션 ID) → 앱 세션" 매핑을 유지해야 합니다. 무상태 JWT만으로 세션을 운영하면 이 요구를 충족할 수 없다는 점이, "앱 세션은 서버 측 세션 + IdP 토큰은 백엔드 보관"이라는 BFF 구조를 미는 또 하나의 이유입니다.
OAuth 2.1 시대의 OIDC — 2026년 정합성 정리
OAuth 2.1 드래프트의 제약을 OIDC 구현에 대입하면 다음과 같습니다.
- Implicit/Hybrid에서 id_token을 front-channel로 받던 패턴 폐기 — response_type=code 단일화. form_post가 필요한 특수 케이스가 아니라면 fragment로 토큰을 받지 않습니다.
- PKCE 전면 적용 — OIDC의 nonce가 있어도 PKCE는 별도로 필요합니다. nonce는 ID Token 주입을, PKCE는 code 탈취를 막습니다. 방어 대상이 다릅니다.
- refresh token 회전 — public 클라이언트는 회전 + 재사용 감지를 켭니다. 재사용이 감지되면 토큰 패밀리 전체를 폐기합니다.
- redirect_uri 정확 일치 — 와일드카드, 부분 일치 전부 금지.
- Bearer 토큰의 쿼리 스트링 전달 금지 — 헤더 또는 POST body만.
추가로 고보안 도메인이라면 FAPI 2.0 Security Profile(Keycloak 26.6에서 Final 지원)을, 비밀 관리 강화가 필요하면 client_secret 대신 private_key_jwt나 mTLS 클라이언트 인증을 검토합니다. JWT Authorization Grant와 federated client authentication 같은 Keycloak 26.6의 신기능은 서비스 간 신뢰 연계 시나리오에서 비밀번호 없는 클라이언트 인증을 가능하게 합니다.
트러블슈팅 노트
| 증상 | 흔한 원인 | 확인 방법 |
|---|---|---|
| invalid_grant (token 교환 실패) | code 재사용, code 만료(수십 초), redirect_uri 불일치, verifier 불일치 | OP 로그에서 거부 사유 확인 |
| 서명 검증 실패가 간헐적 | 키 회전 직후 JWKS 캐시가 낡음 | kid 재조회 로직 유무 점검 |
| 로그인 무한 루프 | 콜백에서 세션 쿠키가 안 붙음 (SameSite, 도메인 불일치) | Set-Cookie 속성과 콜백 도메인 확인 |
| 간헐적 exp/iat 오류 | 서버 간 클럭 스큐 | NTP 상태, skew 허용치 설정 |
| state 불일치 오류 다발 | 다중 탭 로그인, 세션 스토어 TTL 짧음 | state를 탭별로 구분 저장 |
| 모바일에서만 로그인 실패 | 인앱 브라우저(WebView) 차단 정책 | 시스템 브라우저(ASWebAuthenticationSession 등) 사용 |
마치며
OIDC를 한 문장으로 요약하면 "OAuth 2.0의 배관 위에, 서명된 JWT(ID Token)와 메타데이터 자동 발견(Discovery/JWKS)을 얹어 인증을 표준화한 프로토콜"입니다. 실무 관점의 핵심 세 가지를 남깁니다.
- 토큰 세 개의 역할을 섞지 마세요. 로그인은 ID Token, API는 Access Token, 갱신은 Refresh Token. 각자의 audience와 검증 주체가 다릅니다.
- 검증 체크리스트를 코드 리뷰 기준으로 삼으세요. iss/aud/exp/nonce/서명 중 하나라도 빠지면 그 구현은 미완성입니다.
- OAuth 2.1의 제약(Code + PKCE 단일화, refresh 회전, exact redirect_uri)은 이미 현재의 상식입니다. 드래프트 확정을 기다릴 이유가 없습니다.
SAML이 어제의 신뢰를 지탱하고 있다면, OIDC는 오늘과 내일 — passkeys 통합, AI 에이전트 위임, FAPI 2.0 — 이 쌓이는 자리입니다. 프로토콜의 바닥을 이해해 두면 그 위의 어떤 변화도 두렵지 않습니다.
참고 자료
- OpenID Connect Core 1.0
- OpenID Connect Discovery 1.0
- RFC 6749 — The OAuth 2.0 Authorization Framework
- RFC 7636 — Proof Key for Code Exchange (PKCE)
- RFC 7519 — JSON Web Token (JWT)
- RFC 7517 — JSON Web Key (JWK)
- RFC 9700 — Best Current Practice for OAuth 2.0 Security
- RFC 9207 — OAuth 2.0 Authorization Server Issuer Identification
- OAuth 2.1 Draft (draft-ietf-oauth-v2-1)
- OpenID Connect Back-Channel Logout 1.0
- FAPI 2.0 Security Profile
- RFC 8693 — OAuth 2.0 Token Exchange
- Keycloak Documentation
- Keycloak Release Notes