Skip to content

Sub policy (sub / canonical_sub / linked_subs)

Source: en/oauth/sub-policy.md · Live: https://docs.1pass.dev/en/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: ## Why sub and canonical_sub are separated
  • L59-L116: ## What linked_subs is for
  • L117-L125: ## When is_canonical is false
  • L126-L134: ## previously_anonymous
  • L135-L138: ## OIDC compatibility caveat
  • L139-L148: ## Pairwise sub — guaranteeing isolation between RPs
  • L149-L203: ## Identifying users in a multi-client (multi-platform) RP
    • L164-L187: ### A common misconception — "doesn't canonical_sub also diverge for a new pre-merge user?"
    • L188-L203: ### Symptoms when implemented wrong (troubleshooting)
  • L204-L212: ## References

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.

⚠️ Warning: 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".

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:

💡 Tip: 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.

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가 제품의 신뢰를 만듭니다.