Skip to content

Identity model

There are several distinct kinds of identity in the system. They look similar in places. They are not interchangeable.

The identity stack

External IdP                       (Google, GitHub, …)
   |  (iss, sub)
   v
Principal               (HiveCast)  p_<sha20>
   |
   v
Space                  (HiveCast)  spc_<hex>
   |
   v
authorityRoot                       e.g. space.spc-7f3a9b2c
   |
   +-- Host Link / Device           hostlink_<hex> / host_<hex>
   |
   +-- Public namespace claim       space.<spacePath>

External identity → principal

The cloud system.auth actor maintains an externalIdentities[] table mapping (issuer, subject) from each IdP to a stable principalId. HostPrincipalStore.findOrCreate() in host-auth.ts:324-358:

ts
const id = stablePrincipalId(issuer, subject);
// stablePrincipalId(): SHA-256(`${issuer}\n${subject}`).slice(0, 20) → 'p_<hex>'

The principalId is therefore stable: even if the principal's display name or email changes, the id derived from (issuer, subject) does not. Re-signing-in always lands the same principal record.

Principal → Space → authorityRoot

A principal owns one or more Spaces. A Space has:

  • spaceId — durable id, spc_<hex> or legacy space_<20 hex>.
  • authorityRoot — the bus / security / runtime root used by everything inside this Space. For new Spaces, derived from spaceId (e.g. space.spc-7f3a9b2c). Legacy upper-case SPACE.<UPPERCASE-ROUTEKEY> values remain readable.
  • Optional publicNamespace — a typed namespace claim like space.alt.stories.ghost-stories.funny.
  • Optional routeKey — v1 alias for spacePath.

Per WORKSTREAMS/core-and-packaging/MATRIX-AUTHORITY-MODEL.md § "Public Space identity stack":

ConceptFieldExample
User-facing public pathspacePathalt.stories.ghost-stories.funny
Typed namespace claimpublicNamespacespace.alt.stories.ghost-stories.funny
Durable Space identityspaceIdspc_7f3a9b2c
Internal authority rootauthorityRootspace.spc-7f3a9b2c

spacePath and publicNamespace are user-facing / API names. New URLs should use spacePath. New code uses the canonical publicNamespace internally.

The phrase "route key" appears in routeKey fields and the CLI flag --route-key. It is a v1 compatibility alias. Treat it as "Space path" in user-facing copy.

Default Space rule

A principal's principalDefaultSpaces[] table records which Space is that principal's default. HostSpaceStore.getDefaultForPrincipal() is the lookup. When a Host Link is created without explicit Space hints, the cloud defaults to the principal's default Space (creating one if none exists yet).

Email-derived authority root (legacy / local)

For local-owner Hosts and historical pairings, the authority root is derived from the principal's email:

richard.santomauro@nimbletec.com → COM.NIMBLETEC.RICHARD-SANTOMAURO

Email TLD reversed, capitalised, hyphens preserved. This pattern is the v1 default. New Spaces use space.<spaceId> instead, derived from the durable spaceId. Legacy email-derived roots remain readable to preserve continuity.

Device identity

A Device has its own identity stack, separate from the principal:

ConceptFieldLifetime
Install identityinstallId (install_<20 hex>)per-install, never re-keyed
Link identityhostId (host_<20 hex>)per-pairing; reused across re-pairings of the same install
Link recordhostLinkId (hostlink_<24 hex>)per-pairing; new id on each pair
Mutable labelhostNamemutable user-facing label
Stable projection keydeviceSlugper pairing within a principal+Space

See docs-devices/overview/device-model.md for the full Device identity table. The displayName field is forbidden as a Device label — displayName is principal/user identity only.

Identity from transport metadata

Per CLAUDE.md Rule 5:

Identity from transport metadata, NEVER from payload. Transport metadata = authenticated identity. Payload = untrusted. Never trust payload.principalId.

Implications:

  • HTTP requests: identity is the validated session JWT (cookie or Bearer header), not the body's principalId field.
  • NATS request-reply: identity is the authenticated NATS user (the authorized_user claim), not a body field.
  • Bearer-token APIs (e.g. heartbeat): identity is whatever verifyHeartbeatToken() returns from the bearer, not body fields.

SystemAuthActor.onAuthIdentityResolve() is the canonical "given these HTTP headers, what identity do they prove?" call. Other actors that need identity should call into it via auth.identity.resolve rather than re-implementing.

Sessions

HostSessionService (in host-auth.ts:196-313) issues two flavors of JWT:

TokenTTLUse
mx_session cookie7 days (SESSION_DURATION_MS)browser session
Bus token5 minutes (BUS_TOKEN_DURATION_MS)scoped bus authentication

Both are HS256-signed by a 32+ byte secret (auth.sessionSecret). The service rejects sessions with bad signature, wrong issuer, expired exp, or revoked jti. Revoked-session JTIs are kept in revokedSessions[] until their exp (then garbage collected).

See also

Source: projects/matrix-3/packages/system-auth/src/host-auth.ts (HostPrincipalStore, HostSpaceStore, HostNamespaceStore, HostSessionService); projects/matrix-3/packages/system-auth/src/google-oidc.ts (stablePrincipalId consumers).