Skip to content

Sub 정책 (sub / canonical_sub / linked_subs)

Source: oauth/sub-policy.md · Live: https://docs.1pass.dev/oauth/sub-policy LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).

📍 Jump-to Index

  • L33-L58: ## 왜 sub 와 canonical_sub 를 분리했나
  • L59-L116: ## linked_subs 의 용도
  • L117-L125: ## is_canonical 이 false 일 때
  • L126-L134: ## previously_anonymous
  • L135-L138: ## OIDC 호환성 caveat
  • L139-L154: ## Pairwise sub — RP 간 격리 보장
  • L155-L203: ## 멀티-client(멀티플랫폼) RP 의 사용자 식별
    • L168-L188: ### 흔한 오해 — "merge 전 신규 사용자는 canonical_sub 도 갈리지 않나?"
    • L189-L203: ### 잘못 구현 시 증상 (troubleshooting)
  • L204-L212: ## 참고

Sub 정책

logi 가 /api/v1/oauth/userinfoid_token 에 emit 하는 identity claim 은 다섯 가지입니다.

Claim타입의미
substring이 RP(client)별 pairwise subject identifier. user.id 자체가 아니라 RP별 솔트로 만든 opaque hash 입니다. 한 RP grant 안에서는 불변(stable) — RP grant 가 처음 발급된 시점의 user 에 고정되지만, 그 값은 user.id 가 아니라 pairwise hash 입니다. 자세한 내용은 아래 Pairwise sub 섹션 참고.
canonical_substring같은 사람의 현재 살아있는(canonical) logi user.id. 통합 발생 시 변경됨.
is_canonicalbooleansub == canonical_sub 의 편의 alias.
linked_subsobject[]이 canonical 에 흡수된 다른 user.id 들의 메타데이터 배열 (자기 자신 제외). 각 원소는 {sub, merged_canonical_sub, merged_via, occurred_at, source_event_id} 형태. 상세 스키마는 아래 참고.
previously_anonymousboolean이 user 가 익명 가입 후 SSO 로 promoted 된 적이 있는지.

왜 sub 와 canonical_sub 를 분리했나

OIDC 표준은 sub 가 RP 별로 stable 해야 한다고 명시합니다 (RFC 6749 와 OIDC Core §2). 만약 통합이 발생할 때마다 sub 가 바뀌면:

  • RP 가 이전 sub 로 저장해둔 외래키가 모두 끊깁니다.
  • 캐시된 JWT 의 sub 가 갑자기 의미를 잃습니다.
  • OIDC validator 가 토큰 회전 시 reject 할 수 있습니다.

logi 는 다음 규칙을 따릅니다:

  • sub 은 절대 안 바뀝니다. RP grant 가 처음 만들어진 시점의 user 에 고정된 pairwise hash 값이 영구적으로 박힙니다 (값은 user.id 가 아닙니다 — 아래 Pairwise 섹션 참고). 그 user 가 나중에 다른 user 로 흡수되더라도 grant 의 sub 는 그대로.
  • canonical_sub 는 통합 시점에 갱신됩니다. 값은 현재 살아있는 logi user.id (user.canonical_id). RP 는 매 토큰 검증 시 이 값을 보고 "이 user 의 실제 정체성은 누구인가" 를 판단합니다.

따라서 권장 RP 패턴은:

RP DB 의 외래키 = sub (RP별 pairwise, 그대로 보존, 마이그레이션 불필요)
런타임 user lookup = canonical_sub 로 한 번 해석 → 실제 user row

⚠️ 단, 같은 RP 가 web/ios/android 등 여러 client_id 를 쓴다면
   sub 은 client 마다 다르므로 사람 단위 식별은 canonical_sub 로 해야 함
   (아래 Pairwise sub 섹션 참고)

자세한 마이그레이션 절차는 RP Migration Guide 참고.

linked_subs 의 용도

linked_subs 는 canonical 한 user 에 흡수된 다른 user.id 들의 메타데이터 배열입니다. 각 원소는 단순 string 이 아니라 다음 필드를 가진 object 입니다:

필드타입의미
substring흡수된 user 의 id (RP 가 과거 외래키로 저장했을 값).
merged_canonical_substring흡수 시점의 survivor user.id. 보통 현재 canonical_sub 와 동일.
merged_viastring통합 트리거 종류 (예: "otp", "session_token", "sso_email_match").
occurred_atstring | null통합 발생 시각 (ISO 8601).
source_event_idstring | null통합을 발생시킨 event_id (audit 추적용). 이벤트 백필 전 통합은 null.

예를 들어 A → B 통합이 일어난 뒤 user B 의 userinfo 는:

json
{
  "sub": "B",
  "canonical_sub": "B",
  "is_canonical": true,
  "linked_subs": [
    {
      "sub": "A",
      "merged_canonical_sub": "B",
      "merged_via": "otp",
      "occurred_at": "2026-05-11T12:34:56Z",
      "source_event_id": "evt_01HE3..."
    }
  ]
}

A 가 더 일찍 grant 를 받았던 RP 가 token 을 새로 받으면:

json
{
  "sub": "A",
  "canonical_sub": "B",
  "is_canonical": false,
  "linked_subs": []
}

여기서 linked_subs 가 빈 배열인 이유는 A 가 흡수된 쪽이지 흡수한 쪽이 아니기 때문입니다. 흡수한 쪽(survivor)linked_subs 에 흡수된 user 들이 들어옵니다.

⚠️ Warning: RP 파싱 주의 linked_subs.forEach(s => map[s] = canonical) 처럼 string 으로 가정해서 처리하면 [object Object] key 가 됩니다. 반드시 link.sub 으로 꺼내 쓰세요:

js
linked_subs.forEach((link) => {
  legacyToCanonical[link.sub] = link.merged_canonical_sub;
});

RP 는 이 정보를 다음 용도로 씁니다:

  • 자기 DB 의 두 row (sub=A, sub=B) 가 logi 입장에서는 같은 사람임을 인식.
  • 두 row 의 도메인 데이터를 통합하거나, 적어도 같은 사용자가 양쪽 데이터를 모두 볼 수 있게 권한 분배.
  • source_event_idoccurred_at 으로 자기 audit log 에서 해당 머지 이벤트 추적.

is_canonical 이 false 일 때

이 grant 는 흡수된 user 가 과거에 받은 것입니다. RP 는 두 가지 선택지가 있습니다:

  1. Canonical 으로 재해석 (권장) — canonical_sub 를 외래키 lookup 의 source 로 사용. RP 의 domain row 가 canonical user 와 연결되어 있다면 그대로 작동. 안 되어 있다면 logi_identity_links 를 통과한 resolver 가 필요.
  2. 기존 sub 유지sub 로 lookup 하고 canonical 변경에 무관심. 단, 같은 사람의 다른 grant 가 들어왔을 때 두 row 로 인식하게 됨.

대부분의 RP 는 1번 패턴을 채택했고, logi 의 Rails/Swift/Kotlin SDK 도 1번을 기본값으로 가정합니다.

previously_anonymous

익명-우선 가입 흐름(v0.4)에서 user 가 처음에 익명 row 로 만들어지고 나중에 SSO 가 붙으면 previously_anonymous: true 로 마킹됩니다. 이 값은:

  • 사용자가 자기 데이터에 대해 "익명 시절 데이터 포함" 여부를 인지하도록 RP UI 에 노출 가능.
  • GDPR/개인정보처리방침상 익명-promoted user 와 처음부터 식별된 user 를 구분할 필요가 있을 때 사용.

previously_anonymous 는 한 번 true 가 되면 false 로 돌아가지 않습니다.

OIDC 호환성 caveat

순수 OIDC validator (예: Auth0 SDK, Keycloak 토큰 검사기) 는 canonical_sub / linked_subs 같은 logi-specific claim 을 모릅니다. 이는 의도된 동작 입니다 — 추가 claim 은 OIDC 표준의 "additional claims" 영역에 들어가며, 표준 validator 는 무시하고 sub 만 봅니다. 통합 직후 표준 validator 가 같은 사람을 두 row 로 보더라도 이는 RP 가 logi-specific claim 을 처리할 때까지의 일시적 갭이며, 다음 토큰 회전 또는 polling 동기화 후 정정됩니다.

Pairwise sub — RP 간 격리 보장

subOauthApplication 별로 다르게 발급됩니다 (OIDC §8.1 Pairwise Subject Identifier). 같은 logi user 가 두 RP 에 동시 로그인해도 RP A 와 RP B 가 받는 sub 는 서로 다른 값. 두 RP 가 사용자 데이터를 합쳐도 같은 사람인지 식별 불가 (correlation 공격면 차단).

구현: oauth_applications.pairwise_salt (앱별 48-byte 랜덤 솔트) + HMAC.

함정: 같은 RP 의 web clientmobile client 는 별개 OauthApplication 이므로 서로 다른 sub 를 받습니다. RP 측에서 한 사용자가 두 row 로 보이는데 identity_links 로 묶을 수 있습니다.

회귀 방지 spec: spec/integrations/krx_listing_rp_integration_spec.rb#"issues distinct pairwise-sub per RP for the same user".

멀티-client(멀티플랫폼) RP 의 사용자 식별

웹·iOS·Android 를 각각 별개 client_id 로 등록한 RP 가 가장 흔히 밟는 함정입니다. 앞 절에서 본 것처럼 sub 는 client(OauthApplication) 별 pairwise hash 라서, 같은 사람이 플랫폼을 바꿔 로그인하면 sub 가 갈립니다. 따라서:

💡 Tip: 결론: 식별 키는 pairwise sub 가 아니라 canonical_sub 멀티-client RP 가 사용자를 묶는 외래키는 반드시 canonical_sub 여야 합니다. sub 로 사람을 식별하면 두 번째 플랫폼 로그인이 "다른 사람" 으로 들어옵니다.

userinfo 는 canonical_sub 와 편의 alias is_canonical항상 emit 하므로, RP 는 별도 호출 없이 매 로그인에서 이 값을 받습니다.

흔한 오해 — "merge 전 신규 사용자는 canonical_sub 도 갈리지 않나?"

아닙니다. canonical_sub 는 client 와 무관하게 같은 logi user.id 입니다.

  • sub 는 client 별 솔트로 해시되므로 web/iOS/Android 가 서로 다릅니다 (pairwise).
  • canonical_sub 는 그 user 의 살아있는 logi user.id 그 자체이며, 어느 client 로 로그인하든 동일한 값 입니다. merge 가 한 번도 일어난 적 없는 신규 멀티-client 사용자도 세 플랫폼 모두에서 같은 canonical_sub 를 받습니다.
  • canonical_sub 가 바뀌는 유일한 경우는 그 user 가 다른 user 로 흡수(merge)될 때뿐 입니다 (위 linked_subs 참고).

즉 "merge 전에는 식별이 안 되니까 sub 도 같이 봐야 한다" 는 보정 로직은 불필요 합니다. canonical_sub 단독 키잉이 merge 전·후 모두에서 충분한 정답 입니다.

멀티-client RP 의 사용자 외래키 = canonical_sub
  (web/iOS/Android 어느 쪽 로그인이든 같은 값 → 한 사람 = 한 row)

sub 는 grant(=client)별 식별·audit 용으로만 보조 저장

잘못 구현 시 증상 (troubleshooting)

RP 가 사람 단위 식별을 canonical_sub 가 아니라 pairwise sub 로 하면, 두 번째 플랫폼에서 로그인할 때 RP 가 "이미 다른 sub 로 연결된 사용자" 로 오인합니다. 전형적인 증상:

  • RP 가 AlreadyLinkedError 를 던지거나, 사용자에게 "이미 다른 계정과 연결되어 있습니다" 류의 메시지를 보여줌.
  • 첫 플랫폼(예: 웹)으로는 로그인되는데, 두 번째 플랫폼(예: iOS/Android)에서만 연결 실패가 재현됨.
  • RP DB 에 같은 사람이 플랫폼 수만큼 분리된 row 로 쌓임.

→ 원인은 거의 항상 pairwise sub 로 사용자 키잉. 식별 키를 canonical_sub 로 바꾸면 해소됩니다. 이미 분리 저장된 row 는 canonical_sub 기준으로 병합하세요.

참고

Identity가 제품의 신뢰를 만듭니다.