Navigation

OIDC Social Login Flow

모바일 웹뷰 서비스에 카카오와 애플 로그인을 붙이면서 OIDC 흐름을 정리한 적이 있습니다. 처음 쓴 노트는 두 공급자를 oidc-client-ts의 같은 리다이렉트 패턴으로 설명했지만 실제 구현은 달랐습니다. 카카오는 웹 팝업에서 받은 ID 토큰을, 애플은 네이티브 앱이 받은 authorization code를 서비스 API로 보냈습니다. 이번에 OpenID Connect Core, 공급자 문서, oidc-client-ts 3.5.0 소스로 각 단계를 다시 확인하고 노트에 빠져 있던 보안 판단과 계정 연결 순서를 함께 적었습니다.

클라이언트가 확인하는 세 값

OIDC는 OAuth 2.0 위에 인증을 얹은 규격입니다. 로그인을 요청하는 애플리케이션(Relying Party)이 인증 서버(OpenID Provider)로 사용자를 보내고 돌아온 authorization code를 토큰 엔드포인트에서 ID 토큰으로 바꿉니다. ID 토큰은 누가 언제 어느 클라이언트를 위해 인증했는지 담은 JWT입니다.

이 흐름에서 요청과 응답을 묶는 값이 세 가지 있습니다. 셋 다 “이 응답이 내가 보낸 요청의 결과인가”를 확인하지만 막는 공격이 다릅니다.

값만드는 곳확인하는 곳막는 것
state클라이언트, 요청마다콜백에서 요청 때 값과 비교다른 요청의 응답을 끼워 넣는 CSRF
nonce클라이언트 세션ID 토큰의 nonce 클레임과 비교ID 토큰 재사용(replay)
PKCE code_verifier클라이언트, 요청마다토큰 엔드포인트가 code_challenge와 비교가로챈 authorization code 사용

OIDC Core는 nonce를 클라이언트 세션과 ID 토큰을 연결해 재사용 공격을 줄이는 값으로 정의합니다. 추측할 수 없을 만큼 무작위여야 한다는 조건도 붙습니다. OAuth 2.0 보안 모범 사례인 RFC 9700(2025년 1월)은 공개 클라이언트가 PKCE를 반드시 써야 한다고 정했습니다. 브라우저에서 토큰을 교환하는 SPA가 여기에 해당합니다.

ID 토큰 검증 규칙도 확인해야 합니다. OIDC Core 3.1.3.7은 iss가 발급자와 정확히 같고 aud에 자기 client_id가 있으며 exp가 지나지 않았고 요청에 보낸 nonce와 같은지 보라고 합니다. 서명 검증에는 예외가 하나 있습니다. 토큰 엔드포인트에서 TLS로 직접 받은 ID 토큰은 TLS 서버 검증으로 서명 확인을 대신할 수 있습니다. 이 예외가 뒤의 판단을 정합니다.

카카오: 팝업에서 받은 ID 토큰을 서버가 다시 검증합니다

카카오 로그인은 앱 설정에서 OpenID Connect를 켜야 ID 토큰을 줍니다. scope를 따로 지정하면 openid를 포함해야 하고 재사용 공격을 막도록 nonce 사용을 권장합니다. 문서는 발급받은 ID 토큰을 사용 전에 반드시 검증하라고 하고 iss, aud(앱 키), exp, nonce, 공개 키로 RS256 서명을 확인하는 순서를 안내합니다. 공개 키는 일정 기간 캐시하라고 권합니다. 너무 자주 요청하면 차단될 수 있어서입니다.

2026-10-25에 카카오의 discovery 문서를 다시 받아 보니 response_types_supported는 code뿐이었고 code_challenge_methods_supported에 S256이 있었습니다. 토큰 엔드포인트 인증 방식은 client_secret_post 하나였고 같은 날 토큰 엔드포인트의 CORS preflight는 모든 출처를 허용했습니다. 브라우저에서 PKCE로 코드를 교환할 수 있는 조건은 갖춘 셈입니다.

걸리는 부분은 client secret입니다. 카카오 REST API 문서에 따르면 client secret 기능은 기본으로 켜져 있고 켜져 있으면 토큰 요청에 client_secret이 필요합니다. 브라우저 번들에 넣은 값은 비밀이 될 수 없습니다. 브라우저에서 교환하려면 client secret을 끄고 PKCE와 등록된 redirect URI에 기대야 하며 그러지 않으려면 교환을 서버로 옮겨야 합니다. 처음 정리한 노트에는 이 판단이 적혀 있지 않았습니다.

sequenceDiagram
    participant Page as 로그인 화면
    participant Popup as 팝업 창
    participant OP as 카카오 인증 서버
    participant API as 서비스 API
    Page->>API: 로그인 화면 진입 때 nonce 요청
    API-->>Page: 세션에 묶은 nonce
    Page->>Popup: 클릭과 함께 창 열기, state와 code_verifier 생성
    Popup->>OP: authorize 요청(state, nonce, code_challenge)
    OP-->>Popup: 콜백 URL로 code, state
    Popup-->>Page: postMessage(콜백 URL)
    Page->>OP: 토큰 요청(code, code_verifier)
    OP-->>Page: id_token, access_token
    Page->>API: id_token
    API->>API: 서명, iss, aud, exp, nonce 검증
    API-->>Page: 서비스 토큰

oidc-client-ts 3.5.0 소스도 열어 봤습니다. PKCE는 기본으로 켜져 있고(disablePKCE 기본값 false) state는 요청마다 자동으로 만듭니다. nonce는 자동으로 만들지 않아 signinPopup({ nonce })처럼 넘긴 경우에만 요청에 붙고 돌아온 ID 토큰과 비교합니다. ID 토큰은 디코드해 sub와 nonce만 확인하고 서명은 검증하지 않습니다. 토큰 엔드포인트에서 직접 받았으니 OIDC Core의 TLS 예외 범위 안입니다.

당시 서비스에서는 oidc-client-ts 3.3을 썼습니다. 응답 방식과 PKCE, state는 라이브러리 기본값인 인가 코드 흐름을 따랐고 브라우저 번들에는 client secret을 두지 않았습니다. 브라우저 앱을 공개 클라이언트로 보고 비밀값이 필요한 검증은 서버로 넘긴 구성입니다.

서비스 API가 받는 ID 토큰은 사정이 다릅니다. 서버는 그 토큰을 토큰 엔드포인트가 아니라 브라우저에서 받았습니다. 그래서 서버가 공개 키로 서명을 확인하고 iss, aud, exp, nonce를 다시 봐야 합니다. nonce도 서버가 만들어 세션에 묶어 둔 값이어야 의미가 있습니다. 브라우저가 만든 값을 토큰과 함께 보내면 공격자도 토큰 안의 값을 그대로 따라 보낼 수 있습니다. 애플 문서도 앱 서버와 인증 세션을 먼저 시작하고 nonce로 클라이언트 세션과 ID 토큰을 연결하라고 안내합니다.

nonce를 어디서 만들고 누가 비교하느냐에 따라 막을 수 있는 범위가 달라집니다. OIDC Core 15.5.2는 nonce에 세션별 상태가 담겨야 하고 공격자가 추측할 수 없어야 한다고 설명합니다. 웹 서버 클라이언트에는 무작위 값을 HttpOnly 세션 쿠키에 두고 그 해시를 nonce로 보내는 방법을, 브라우저 클라이언트에는 무작위 값을 브라우저 저장소에 두는 방법을 예로 듭니다. 브라우저가 받은 ID 토큰을 다시 서버로 넘기는 구조라면 마지막 비교는 서버가 해야 합니다.

방식비교하는 곳남는 문제
브라우저가 만들고 브라우저가 비교브라우저서버는 받은 토큰이 이 로그인 시도에서 나왔는지 모름
브라우저가 만든 값을 토큰과 함께 서버로 전송서버가 요청 값과 클레임 비교토큰을 가진 공격자도 클레임을 읽어 같은 값을 보낼 수 있음
서버가 쓴 nonce를 기록하고 두 번째 사용 거부서버의 사용 기록탈취한 토큰을 먼저 쓰면 통과, 기록 보존 기간이 토큰 수명보다 짧으면 다시 통과
서버가 발급해 세션에 묶음서버가 세션 값과 클레임을 비교한 뒤 폐기로그인 화면 진입 때 요청이 하나 늘어남

마지막 방식이 OIDC Core가 말하는 세션 결합에 가장 가깝습니다. 서버 쪽은 아래 정도면 됩니다.

서버: 세션에 묶은 nonce를 한 번만 받아 줍니다ts
app.post('/auth/oidc/nonce', (req, res) => {
  const nonce = randomBytes(32).toString('base64url')
  req.session.oidcNonce = nonce // 로그인을 시작한 세션에 묶습니다
  res.json({ nonce })
})

app.post('/auth/social', async (req, res) => {
  // 서명, iss, aud, exp를 먼저 확인합니다
  const claims = await verifyIdToken(req.body.idToken, { issuer: ISSUER, audience: CLIENT_ID })
  const expected = req.session.oidcNonce
  delete req.session.oidcNonce // 성공과 실패에 상관없이 한 번만 씁니다
  if (!expected || claims.nonce !== expected) return res.status(401).end()
  res.json(await signInWithProvider(claims.iss, claims.sub))
})
팝업으로 받은 ID 토큰을 서비스 API로 넘깁니다ts
import { UserManager } from 'oidc-client-ts'

const kakao = new UserManager({
  authority: 'https://kauth.kakao.com',
  client_id: import.meta.env.VITE_KAKAO_REST_API_KEY,
  redirect_uri: `${location.origin}/oauth/callback`,
  popup_redirect_uri: `${location.origin}/oauth/popup-callback`,
})

// 로그인 화면에 들어올 때 서버 세션에 묶인 nonce를 미리 받아 둡니다
let nonce: string | undefined
export async function prepareLogin() {
  nonce = (await api.post('/auth/oidc/nonce', { provider: 'kakao' })).nonce
}

// 클릭 핸들러에서 다른 await 없이 바로 부릅니다
export async function loginWithKakao() {
  const user = await kakao.signinPopup({ nonce })
  if (!user.id_token) throw new Error('ID 토큰이 없습니다')
  const session = await api.post('/auth/social', { provider: 'kakao', idToken: user.id_token })
  await kakao.removeUser() // 공급자 토큰은 더 쓰지 않으므로 지웁니다
  return session
}

nonce를 미리 받는 이유는 팝업 차단입니다. MDN은 팝업 창이 사용자 입력에 직접 반응해 열려야 한다고 설명합니다. oidc-client-ts 3.5.0의 signinPopup()은 요청 URL을 만들기 전에 창부터 열지만 그 앞에 네트워크 요청을 기다리면 창을 여는 시점이 클릭에서 멀어집니다.

팝업을 고른 이유도 있었습니다. 스택형 화면 이동을 쓰는 모바일 웹에서 전체 페이지 리다이렉트를 하면 메모리에 있던 화면 스택이 사라집니다. 처음에는 리다이렉트 전에 화면 목록을 localStorage에 저장하고 콜백에서 다시 쌓았습니다. 팝업으로 바꾸자 로그인 화면이 그대로 남아 복원 단계가 필요 없어졌습니다.

팝업 응답은 출처, 발신 창, 메시지 모양으로 거릅니다

oidc-client-ts 3.5.0의 팝업 콜백 페이지는 signinPopupCallback()을 호출합니다. 이 함수는 콜백 URL을 window.opener에 postMessage로 보내는데 targetOrigin을 자기 출처로 고정합니다. 부모 창의 리스너는 출처가 자기와 다르거나 라이브러리의 메시지 표식이 없으면 무시합니다. 발신 창이 자기가 연 팝업이 아니면 state가 일치할 때만 받습니다. opener 참조를 잃은 경우에는 state로 이름 붙인 BroadcastChannel로 결과를 전달합니다. 결국 popup_redirect_uri는 로그인 화면과 같은 출처여야 합니다.

MDN의 postMessage 보안 항목도 같은 기준을 둡니다. 받는 쪽은 origin과 필요하면 source로 발신자를 확인하고 그 뒤에도 메시지의 형식을 검사합니다. 보내는 쪽은 * 대신 정확한 대상 출처를 지정합니다.

본인인증처럼 라이브러리 없이 직접 띄우는 팝업에도 같은 세 조건을 적용했습니다. 출처가 현재 출처와 같은지, 발신 창이 방금 연 팝업인지, 데이터가 약속한 타입인지를 모두 통과한 메시지만 처리합니다. 메시지에는 다음 화면을 정할 값만 담고 인증 결과 자체는 서버 API로 다시 확인했습니다. 사용자가 팝업을 그냥 닫으면 메시지가 오지 않으므로 닫힘도 따로 감시합니다.

직접 연 팝업의 결과만 받습니다ts
type AuthMessage = { type: 'identity-verified'; action: 'signup' | 'login' | 'failed' }

const isAuthMessage = (data: unknown): data is AuthMessage =>
  typeof data === 'object' && data !== null &&
  (data as { type?: unknown }).type === 'identity-verified'

export function waitForAuthMessage(popup: Window): Promise<AuthMessage> {
  return new Promise((resolve, reject) => {
    const onMessage = (event: MessageEvent) => {
      if (event.origin !== window.location.origin) return // 다른 출처
      if (event.source !== popup) return // 방금 연 팝업이 아님
      if (!isAuthMessage(event.data)) return // 약속한 모양이 아님
      cleanup()
      resolve(event.data)
    }
    const timer = window.setInterval(() => {
      if (!popup.closed) return
      cleanup()
      reject(new Error('팝업이 닫혔습니다'))
    }, 300)
    const cleanup = () => {
      window.removeEventListener('message', onMessage)
      window.clearInterval(timer)
    }
    window.addEventListener('message', onMessage)
  })
}

반환 화면은 window.opener?.postMessage(message, window.location.origin)처럼 대상 출처를 고정해 보냅니다. window.open()이 차단되면 null을 돌려주므로 호출한 쪽에서 안내를 띄우고 멈춥니다.

애플: 네이티브 앱이 받은 authorization code를 서버가 교환합니다

애플은 웹 흐름부터 SPA와 맞지 않았습니다. 애플 문서의 인증 요청 예시는 이름과 이메일을 요청할 때 response_mode=form_post를 쓰고 결과를 redirect URI로 HTTP POST합니다. 정적 파일로 서빙되는 SPA 경로는 POST 본문을 읽을 수 없고 oidc-client-ts 3.5.0의 response_mode도 query와 fragment만 받습니다. redirect URI는 HTTPS여야 하고 도메인 이름이 있어야 하며 IP 주소와 localhost를 쓸 수 없습니다. 로컬 개발에서도 hosts 파일에 개발용 도메인을 등록하고 로컬 HTTPS를 켜 반환 주소를 맞췄습니다.

iOS 앱 안에서는 네이티브 로그인 결과를 쓰는 편이 자연스러웠습니다. AuthenticationServices의 ASAuthorizationAppleIDCredential은 authorizationCode와 identityToken을 줍니다. 웹은 브리지로 네이티브 로그인을 요청하고 돌아온 authorization code를 서비스 API에 넘깁니다.

sequenceDiagram
    participant Web as 웹뷰 화면
    participant App as 네이티브 앱
    participant Apple as Apple 인증 서버
    participant API as 서비스 API
    Web->>App: 브리지로 애플 로그인 요청
    App->>Apple: Sign in with Apple
    Apple-->>App: authorizationCode, identityToken
    App-->>Web: authorizationCode
    Web->>API: authorizationCode
    API->>Apple: /auth/token(code, client_secret JWT)
    Apple-->>API: id_token, refresh_token
    API-->>Web: 서비스 토큰

애플의 토큰 검증 문서에 따르면 authorization code는 한 번만 쓸 수 있고 5분 동안 유효합니다. 교환 요청의 client_secret은 개발자 계정의 Sign in with Apple 개인 키로 서명한 JWT라서 서버에만 둘 수 있습니다. 웹은 코드를 받는 즉시 서버로 보내야 하고 실패하면 같은 코드로 재시도하기보다 로그인을 처음부터 다시 시작하는 편이 맞습니다. 이름은 처음 로그인할 때만 전달되므로 받은 쪽이 바로 저장해야 합니다. 네이티브 흐름이라면 앱이 받은 이름을 서버까지 넘길 경로가 필요합니다.

구분카카오 웹 로그인애플 네이티브 로그인
웹이 서버로 보내는 값ID 토큰authorization code
공급자와의 토큰 교환브라우저(PKCE)서비스 서버
서버의 확인ID 토큰 서명과 클레임 검증코드 교환으로 받은 ID 토큰 검증
비밀값브라우저에 둘 수 없음서버가 개인 키로 만든 client_secret
실패 뒤 재시도새 팝업 로그인코드는 1회용, 5분 안에 새로 받기

공급자별 설정 파일만 보면 두 로그인이 같은 웹 OIDC 구현처럼 보이지만 받는 값과 검증 주체가 다릅니다. 그래서 서비스 API에도 공급자 타입과 함께 idToken 또는 authorizationCode를 구분해 보냈습니다.

공급자 계정과 서비스 계정을 잇는 순서

토큰 검증이 끝나도 어느 서비스 회원인지 찾는 일이 남습니다. OIDC Core 5.7은 사용자를 안정적으로 가리키는 값이 iss와 sub의 조합뿐이라고 정합니다. email, phone_number, preferred_username, name은 고유 식별자로 쓰면 안 됩니다. 이메일은 바뀔 수 있고 발급자가 다른 사람에게 다시 배정할 수도 있습니다. 그래서 소셜 계정은 발급자와 sub를 키로 저장하고 서비스 회원과는 따로 연결합니다.

이메일이 아니라 iss와 sub로 찾습니다ts
const link = await db.socialAccount.findUnique({
  where: { issuer_subject: { issuer: claims.iss, subject: claims.sub } },
})
if (link) return signIn(link.memberId)
// 이메일이 같다는 이유로 기존 회원에 붙이지 않고 본인 확인 단계로 보냅니다
return { next: 'identity-verification', pending: await savePendingLink(claims.iss, claims.sub) }

iss와 sub로 찾지 못했다고 바로 신규 가입으로 보내면 같은 사람이 계정을 여러 개 갖게 됩니다. 국내 서비스는 본인확인 결과로 받는 연계정보(CI)로 이미 가입한 사람인지 판단하곤 합니다. 제가 참여한 모바일 웹뷰 서비스에서 처음 그린 판단 트리는 아래와 같았습니다.

flowchart TD
    Token["ID 토큰 검증"] --> Find{"iss와 sub로 연결된 회원이 있나"}
    Find -->|있음| SignIn["로그인"]
    Find -->|없음| Verify["본인확인, 연계정보(CI) 확인"]
    Verify --> Same{"같은 CI의 회원이 있나"}
    Same -->|없음| Signup["신규 가입"]
    Same -->|있음| Type{"기존 계정의 가입 방식"}
    Type -->|같은 공급자의 다른 계정| Relink["재연동 안내"]
    Type -->|다른 공급자| Other["다른 소셜 가입 안내"]
    Type -->|직접 가입| Direct["직접 가입 계정 안내"]
    Type -->|소셜 연결 해제 이력| Unlinked["해제 이력 안내"]

분기마다 안내 화면과 문구, 서버 응답 코드가 하나씩 늘어납니다. 논의를 거친 다음 판에서는 재연동 안내와 해제 이력 안내를 빼고 다른 소셜 가입 안내와 직접 가입 계정 안내만 남겼습니다. 직접 가입한 사람은 로그인한 뒤 내 정보 화면에서 소셜 계정 하나를 연결하고 연결한 계정은 해제할 수 없게 했습니다. 해제를 허용하지 않으면 해제 이력을 따질 일도 없습니다. 반대로 해제를 허용하는 서비스라면 해제한 sub를 어떻게 다룰지, 같은 사람이 다시 연결할 때 어떤 안내를 할지까지 정해야 합니다.

토큰 저장과 로그아웃에서 남는 판단

oidc-client-ts 3.5.0의 기본 저장소는 둘입니다. 로그인 중인 요청의 state, code_verifier, nonce는 stateStore(기본 localStorage)에, 로그인한 사용자와 토큰은 userStore(기본 sessionStorage)에 들어갑니다. userStore를 localStorage로 바꾸면 새로고침 뒤에도 공급자 토큰이 남는데 공급자 토큰을 다시 쓰지 않는다면 그럴 이유가 없습니다. 서비스 토큰으로 바꾼 뒤 removeUser()로 지우면 노출 범위가 줄어듭니다.

서비스 토큰을 어디에 두는지는 별도 판단입니다.

위치장점비용
localStorage새로고침과 탭 사이에서 유지, 구현이 단순함XSS가 생기면 스크립트가 토큰을 읽음
메모리스크립트 주입 뒤에도 남는 기간이 짧음새로고침 때마다 다시 받아야 함
HttpOnly 쿠키스크립트가 읽을 수 없음서버의 쿠키 발급, SameSite와 CSRF 대응 필요

localStorage를 고르는 순간 XSS는 곧 토큰 유출입니다. 구현이 단순하다는 이유로 이 자리를 고른다면 그 전제를 받아들인 셈이므로 토큰 수명과 갱신 정책으로 피해 범위를 줄여야 합니다.

로그아웃도 다시 봤습니다. 처음 노트는 서비스 토큰을 지운 뒤 signoutRedirect()로 공급자 세션까지 끊는다고 적었습니다. 2026-10-25에 받은 카카오 discovery 문서에는 end_session_endpoint가 없었고 oidc-client-ts 3.5.0은 이 값이 없으면 로그아웃 요청을 만들지 않고 오류를 냅니다. 공급자 세션 종료가 필요하면 표준 메타데이터 대신 공급자별 로그아웃 방식을 따로 확인해야 합니다.

참고 자료