Skip to content

Sub Policy ​

logi emits five identity claims in /api/v1/oauth/userinfo and in the id_token.

ClaimTypeMeaning
substringA pairwise subject identifier per RP (client). It is not user.id itself but an opaque hash derived from a per-RP salt. Within a single RP grant it is immutable (stable) — it is pinned to the user at the moment the RP grant was first issued, but the value is a pairwise hash, not user.id. See the Pairwise sub section below for details.
canonical_substringThe current, living (canonical) logi user.id of the same person. Changes when a merge occurs.
is_canonicalbooleanA convenience alias for sub == canonical_sub.
linked_subsobject[]An array of metadata for the other user.ids absorbed into this canonical (excluding itself). Each element has the shape {sub, merged_canonical_sub, merged_via, occurred_at, source_event_id}. See the detailed schema below.
previously_anonymousbooleanWhether this user was ever promoted from an anonymous sign-up to SSO.

Why sub and canonical_sub are separated ​

The OIDC standard requires that sub be stable per RP (RFC 6749 and OIDC Core §2). If sub changed every time a merge occurred:

  • Every foreign key the RP stored under the old sub would break.
  • The sub in a cached JWT would suddenly lose its meaning.
  • An OIDC validator could reject the token on rotation.

logi follows these rules:

  • sub never changes. The pairwise hash value pinned to the user at the moment the RP grant was first created is baked in permanently (the value is not user.id — see the Pairwise section below). Even if that user is later absorbed into another user, the grant's sub stays the same.
  • canonical_sub is updated at merge time. Its value is the current, living logi user.id (user.canonical_id). On every token verification, the RP looks at this value to determine "who is this user's actual identity?"

So the recommended RP pattern is:

Foreign key in the RP DB = sub (per-RP pairwise, kept as-is, no migration needed)
Runtime user lookup = resolve canonical_sub once → the actual user row

⚠️ However, if the same RP uses multiple client_ids (e.g. web/ios/android),
   sub differs per client, so per-person identification must use canonical_sub
   (see the Pairwise sub section below)

For the detailed migration procedure, see the RP Migration Guide.

What linked_subs is for ​

linked_subs is an array of metadata for the other user.ids absorbed into a canonical user. Each element is not a plain string but an object with these fields:

FieldTypeMeaning
substringThe absorbed user's id (the value the RP may have stored as a foreign key in the past).
merged_canonical_substringThe survivor user.id at merge time. Usually identical to the current canonical_sub.
merged_viastringThe merge trigger type (e.g. "otp", "session_token", "sso_email_match").
occurred_atstring | nullWhen the merge occurred (ISO 8601).
source_event_idstring | nullThe event_id that triggered the merge (for audit tracing). Merges from before event backfill are null.

For example, after an A → B merge, user B's userinfo is:

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..."
    }
  ]
}

When an RP where A received a grant earlier gets a fresh token:

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

Here linked_subs is an empty array because A is the absorbed side, not the absorbing side. Only the absorbing side (the survivor) gets the absorbed users in its linked_subs.

A note on RP parsing

If you process this assuming a string, like linked_subs.forEach(s => map[s] = canonical), you'll end up with [object Object] keys. Always pull it out as link.sub:

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

The RP uses this information for:

  • Recognizing that two of its own rows (sub=A, sub=B) are the same person from logi's perspective.
  • Merging the domain data of the two rows, or at least distributing permissions so the same user can see both sides' data.
  • Tracing that merge event in its own audit log via source_event_id and occurred_at.

When is_canonical is false ​

This grant was received in the past by an absorbed user. The RP has two options:

  1. Re-resolve to canonical (recommended) — use canonical_sub as the source of the foreign-key lookup. If the RP's domain rows are linked to the canonical user, it works as-is. If not, you need a resolver that goes through logi_identity_links.
  2. Keep the existing sub — look up by sub and ignore canonical changes. The catch: when another grant for the same person comes in, you'll perceive it as two rows.

Most RPs adopt pattern 1, and logi's Rails / Swift / Kotlin SDKs also assume pattern 1 as the default.

previously_anonymous ​

In the anonymous-first sign-up flow (v0.4), the user is created as an anonymous row first, and when SSO is attached later, they are marked previously_anonymous: true. This value can be used:

  • To surface in the RP UI so the user is aware whether their data "includes data from the anonymous period."
  • When you need to distinguish an anonymous-promoted user from one identified from the start, for GDPR / privacy-policy purposes.

Once previously_anonymous becomes true, it never reverts to false.

OIDC compatibility caveat ​

A pure OIDC validator (e.g. the Auth0 SDK, a Keycloak token inspector) does not know about logi-specific claims like canonical_sub / linked_subs. This is intended behavior — the extra claims live in the OIDC standard's "additional claims" area, and a standard validator ignores them and looks only at sub. Even if a standard validator sees the same person as two rows right after a merge, this is a temporary gap until the RP handles the logi-specific claims, and it is corrected after the next token rotation or polling sync.

Pairwise sub — guaranteeing isolation between RPs ​

sub is issued differently per OauthApplication (OIDC §8.1 Pairwise Subject Identifier). Even when the same logi user logs into two RPs at the same time, the sub RP A and RP B receive are different values. Even if the two RPs combine their user data, they cannot tell it's the same person (this blocks the correlation attack surface).

Implementation: sub = SHA256("{sector_identifier}|{user.id}|{salt}")[0,32]. Here sector_identifier is the host of the app's first redirect_uri, and salt is oauth_applications.pairwise_salt, a 48-byte random salt held per app. This is plain SHA-256, not HMAC.

Pitfall: the web client and mobile client of the same RP are separate OauthApplications (unless you assign a sector), so they receive different subs. On the RP side, one user appears as two rows — you can link them via identity_links, or assign a sector below to share the sub itself.

Regression-guard spec: spec/integrations/krx_listing_rp_integration_spec.rb#"issues distinct pairwise-sub per RP for the same user".

Identifying users in a multi-client (multi-platform) RP ​

This is the most common trap for an RP that registers web, iOS, and Android each as a separate client_id. As the previous section shows, sub is a per-client (OauthApplication) pairwise hash, so when the same person logs in from a different platform, the sub diverges. Therefore:

Conclusion: the identity key is canonical_sub, not pairwise sub

A multi-client RP must use canonical_sub as the foreign key that ties a user together. If you identify people by sub, the second platform's login comes in as "a different person."

userinfo always emits canonical_sub and its convenience alias is_canonical, so the RP receives this value on every login with no extra call.

Alternative: share the sub itself with a sector ​

Instead of tying multiple clients together with canonical_sub, you can put the clients of a single service into one sector, which makes the pairwise subitself shared. A sector holds one (sector_identifier, pairwise_salt) pair. SubjectIdentifier uses that pair for every member app instead of each app's own values, so logi issues the same sub to the same user across all of them.

  • No sector (default): each client gets an independent pairwise sub → the identity key is canonical_sub (as in the tip above).
  • Sector assigned: web/mobile/desktop in the same sector get the same sub for the same user → the RP can identify the user directly by sub, and all three platforms still map to a single row.

Only the developer who owns an app can assign it, and only to a sector they also own — cross-owner assignment is rejected. sector_identifier and pairwise_salt are immutable after creation, because changing either one rotates every sub in that sector. Dynamic Client Registration cannot specify a sector at all; that is restricted to the admin/owner console so a client cannot register its way into someone else's sector.

A common misconception — "doesn't canonical_sub also diverge for a new pre-merge user?" ​

No. canonical_sub is the same logi user.id regardless of the client.

  • sub is hashed with a per-client salt, so web/iOS/Android differ (pairwise).
  • canonical_sub is the user's living logi user.id itself, and it is the same value no matter which client you log in from. Even a brand-new multi-client user who has never been merged gets the same canonical_sub on all three platforms.
  • The only time canonical_sub changes is when that user is absorbed (merged) into another user (see linked_subs above).

In other words, the "we still have to look at sub because identification doesn't work before a merge" compensation logic is unnecessary. Keying on canonical_sub alone is the correct, sufficient answer both before and after a merge.

Multi-client RP user foreign key = canonical_sub
  (same value for web/iOS/Android login → one person = one row)

sub is kept only as a secondary per-grant (per-client) identification / audit value

Symptoms when implemented wrong (troubleshooting) ​

If an RP identifies people by pairwise sub instead of canonical_sub, then when the user logs in from a second platform the RP mistakes them for "a user already linked under a different sub." Typical symptoms:

  • The RP throws an AlreadyLinkedError, or shows the user a message like "This is already linked to another account."
  • Login succeeds on the first platform (e.g. web) but the link failure reproduces only on the second platform (e.g. iOS/Android).
  • The RP DB accumulates the same person as separate rows, one per platform.

→ The cause is almost always keying users on the pairwise sub. Switching the identity key to canonical_sub resolves it. Merge any already-split rows by canonical_sub.

References ​

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