Skip to content

필사 모드: OpenID Connect 딥다이브 — Authorization Code Flow부터 Discovery까지

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며

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 밖으로 절대 내보내지 말 것 |

가장 흔한 두 가지 오용:

1. **ID Token을 API에 Bearer로 보내는 것** — ID Token의 aud는 client_id이지 API가 아닙니다. API가 이를 받아주면 audience 검증을 포기한 것입니다.

2. **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의 키 선택 → 서명 검증. 키가 두 개 보이는 것이 정상입니다. **키 회전 중에는 새 키와 옛 키가 공존**하기 때문입니다.

키 회전을 무중단으로 만드는 운영 수칙:

1. OP: 새 키를 JWKS에 먼저 게시하고, 충분한 시간(캐시 TTL 이상)이 지난 뒤에 새 키로 서명을 시작하고, 옛 키로 서명된 토큰이 모두 만료된 뒤에 옛 키를 제거합니다.

2. RP: JWKS를 캐시하되(매 요청 조회 금지), **모르는 kid를 만나면 한 번 재조회**하는 로직을 넣습니다. 단, 재조회에 rate limit을 걸어 DoS를 방지합니다.

3. kid 없는 토큰, JWKS에 없는 kid, alg 불일치는 전부 거부합니다.

UserInfo, 클레임, 스코프

스코프 → 클레임 매핑

scope는 "어떤 클레임 묶음을 원하는가"의 단위입니다.

| scope | 풀리는 표준 클레임 |

| --- | --- |

| openid | sub (OIDC 요청임을 표시, 필수) |

| profile | name, family_name, given_name, preferred_username, picture 등 |

| email | 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 구현에 대입하면 다음과 같습니다.

1. **Implicit/Hybrid에서 id_token을 front-channel로 받던 패턴 폐기** — response_type=code 단일화. form_post가 필요한 특수 케이스가 아니라면 fragment로 토큰을 받지 않습니다.

2. **PKCE 전면 적용** — OIDC의 nonce가 있어도 PKCE는 별도로 필요합니다. nonce는 ID Token 주입을, PKCE는 code 탈취를 막습니다. 방어 대상이 다릅니다.

3. **refresh token 회전** — public 클라이언트는 회전 + 재사용 감지를 켭니다. 재사용이 감지되면 토큰 패밀리 전체를 폐기합니다.

4. **redirect_uri 정확 일치** — 와일드카드, 부분 일치 전부 금지.

5. **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](https://openid.net/specs/openid-connect-core-1_0.html)

- [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)

- [RFC 6749 — The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749)

- [RFC 7636 — Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636)

- [RFC 7519 — JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519)

- [RFC 7517 — JSON Web Key (JWK)](https://datatracker.ietf.org/doc/html/rfc7517)

- [RFC 9700 — Best Current Practice for OAuth 2.0 Security](https://datatracker.ietf.org/doc/html/rfc9700)

- [RFC 9207 — OAuth 2.0 Authorization Server Issuer Identification](https://datatracker.ietf.org/doc/html/rfc9207)

- [OAuth 2.1 Draft (draft-ietf-oauth-v2-1)](https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/)

- [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html)

- [FAPI 2.0 Security Profile](https://openid.net/specs/fapi-2_0-security-profile.html)

- [RFC 8693 — OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693)

- [Keycloak Documentation](https://www.keycloak.org/documentation)

- [Keycloak Release Notes](https://www.keycloak.org/docs/latest/release_notes/index.html)

현재 단락 (1/255)

2026년 현재, 새로 만드는 거의 모든 시스템의 로그인은 OpenID Connect(OIDC)로 수렴했습니다. 소셜 로그인도, 사내 SSO도, 모바일 앱도, CLI 도구의 로그인...

작성 글자: 0원문 글자: 11,482작성 단락: 0/255