Sub Policy
logi emits five identity claims in /api/v1/oauth/userinfo and in the id_token.
| Claim | Type | Meaning |
|---|---|---|
sub | string | The user.id at the time this RP grant was first issued. Immutable (stable). |
canonical_sub | string | The current, living (canonical) user.id of the same person. Changes when a merge occurs. |
is_canonical | boolean | A convenience alias for sub == canonical_sub. |
linked_subs | object[] | 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_anonymous | boolean | Whether 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
subwould break. - The
subin a cached JWT would suddenly lose its meaning. - An OIDC validator could reject the token on rotation.
logi follows these rules:
subnever 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'ssubstays the same.canonical_subis 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 rowFor 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:
| Field | Type | Meaning |
|---|---|---|
sub | string | The absorbed user's id (the value the RP may have stored as a foreign key in the past). |
merged_canonical_sub | string | The survivor user.id at merge time. Usually identical to the current canonical_sub. |
merged_via | string | The merge trigger type (e.g. "otp", "session_token", "sso_email_match"). |
occurred_at | string | null | When the merge occurred (ISO 8601). |
source_event_id | string | null | The 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:
{
"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:
{
"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:
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_idandoccurred_at.
When is_canonical is false
This grant was received in the past by an absorbed user. The RP has two options:
- Re-resolve to canonical (recommended) — use
canonical_subas 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 throughlogi_identity_links. - Keep the existing sub — look up by
suband 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
- Email claim policy — email is mutable; the identifier is sub
- identity_links design
- Event Delivery 3-tier
- RP Migration Guide
- Post-login Destination
- Cross-Host Handoff Threat Model