Skip to content
Published on

OpenID Connect 딥다이브 — Authorization Code Flow부터 Discovery까지

Authors

들어가며

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 TokenAccess TokenRefresh 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 클라이언트

구분confidentialpublic
서버 렌더링 웹 앱, BFFSPA, 모바일, CLI
client_secret보유 (또는 private_key_jwt)없음
token 요청 인증Basic 또는 mTLS/JWTclient_id만
PKCEOAuth 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풀리는 표준 클레임
openidsub (OIDC 요청임을 표시, 필수)
profilename, family_name, given_name, preferred_username, picture 등
emailemail, email_verified
addressaddress
phonephone_number, phone_number_verified
offline_accessrefresh 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 — 두 개의 다른 방패

혼동하기 쉬운 두 파라미터의 역할을 명확히 구분합니다.

항목statenonce
방어 대상CSRF (콜백 위조)ID Token 리플레이/주입
누가 만드나RPRP
어디서 돌아오나콜백의 쿼리 파라미터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 — 이 쌓이는 자리입니다. 프로토콜의 바닥을 이해해 두면 그 위의 어떤 변화도 두렵지 않습니다.

참고 자료