Skip to content

Sub Policy

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

ClaimTypeMeaning
substringThe user.id at the time this RP grant was first issued. Immutable (stable).
canonical_substringThe current, living (canonical) 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 user.id from the moment the RP grant was first created is baked in permanently. Even if that user is later absorbed into another user, the grant's sub stays the same.
  • canonical_sub is updated at merge time. 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 (kept as-is, no migration needed)
Runtime user lookup = resolve canonical_sub once → the actual user row

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: oauth_applications.pairwise_salt (a per-app 48-byte random salt) + HMAC.

Pitfall: the web client and mobile client of the same RP are separate OauthApplications, so they receive different subs. On the RP side, one user appears as two rows, but you can link them via identity_links.

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

References

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