Skip to content

Authorization

In a decentralized system, the only meaningful access control is the ability to decrypt:

Can you decrypt it? → You can read it.
In the recipients list? → The hub will serve it to you.
Schema says you can write? → The client will let you sign a change.

Roles, relations, and UCAN delegation are all machinery for deciding who gets the content key. Once a user has the key, cryptography enforces their access — no server policy engine required.

Add an authorization block to any schema to define who can do what:

import { defineSchema, text, person, relation } from '@xnetjs/data'
import { allow, deny, role } from '@xnetjs/data/auth'
export const TaskSchema = defineSchema({
name: 'Task',
namespace: 'xnet://my-app/',
properties: {
title: text({ required: true }),
assignee: person(),
project: relation({ target: 'xnet://my-app/Project' as const }),
editors: person({ multiple: true })
},
authorization: {
roles: {
owner: role.creator(), // whoever created the node
assignee: role.property('assignee'), // person() property value
editor: role.property('editors'), // person({ multiple: true }) value
admin: role.relation('project', 'admin'), // role from a related node
viewer: role.relation('project', 'viewer') // role from a related node
},
actions: {
read: allow('viewer', 'editor', 'admin', 'owner', 'assignee'),
write: allow('editor', 'admin', 'owner'),
delete: allow('admin', 'owner'),
share: allow('admin', 'owner')
},
publicProps: ['title'] // properties readable without decryption
}
})
BuilderResolves to
role.creator()The DID that created the node
role.property('field')The DID(s) in a person() property
role.relation('field', 'role')Anyone who has that role on the related node
ActionCovers
readstore.get, useQuery, hub queries
writestore.create, store.update, store.restore
deletestore.delete
sharestore.auth.grant, store.auth.revoke
adminHub operational controls

Explicit deny() always wins over allow():

actions: {
read: allow('viewer').deny('blocked'), // 'blocked' role can never read
}

Set publicProps to expose specific fields without encryption, or use PUBLIC to make the entire node readable by anyone:

authorization: {
// ...roles and actions...
publicProps: ['title', 'status'], // these fields are unencrypted
}

For fully public nodes (no access control), use the built-in preset:

import { presets } from '@xnetjs/data/auth'
export const ArticleSchema = defineSchema({
// ...
authorization: presets.public
})
import { presets } from '@xnetjs/data/auth'
// Full owner control — only creator can do anything
authorization: presets.ownerOnly
// Owner + share — owner shares explicit read/write with others via grants
authorization: presets.ownerWithShare
// Collaborative — creator is owner, any member of a 'workspace' relation can read/write
authorization: presets.collaborative({ relation: 'workspace' })
// Fully public — all content is unencrypted and anyone can read
authorization: presets.public
import { useCan } from '@xnetjs/react'
function TaskCard({ taskId }: { taskId: string }) {
const { canRead, canWrite, canDelete, canShare, loading } = useCan(taskId)
if (loading) return null
return (
<div>
<TaskTitle />
{canWrite && <EditButton />}
{canDelete && <DeleteButton />}
{canShare && <ShareButton />}
</div>
)
}

useCan is reactive — it re-evaluates when grants change or when you switch users.

const decision = await store.auth.can({ action: 'write', nodeId: taskId })
// { allowed: true, reason: 'role:owner', cached: false }
const decision = await store.auth.can({ action: 'delete', nodeId: taskId })
// { allowed: false, reason: 'no matching role or grant' }

For debugging or AI-assisted introspection, use explain():

const trace = await store.auth.explain({ action: 'write', nodeId: taskId })
// {
// allowed: true,
// subject: 'did:key:z6Mk...',
// action: 'write',
// nodeId: 'task_abc',
// roles: ['owner'],
// grants: [],
// policyTrace: [
// { rule: 'allow(editor, admin, owner)', matched: 'owner' }
// ]
// }
import { useGrants } from '@xnetjs/react'
function ShareDialog({ taskId }: { taskId: string }) {
const { grants, grant, revoke, loading } = useGrants(taskId)
return (
<div>
{grants.map((g) => (
<div key={g.id}>
{g.grantee}{g.actions.join(', ')}
<button onClick={() => revoke(g.id)}>Revoke</button>
</div>
))}
<button onClick={() => grant({ to: 'did:key:...', actions: ['read'] })}>Add person</button>
</div>
)
}
// Grant access with optional expiry
await store.auth.grant({
to: bobDid,
actions: ['read', 'write'],
resource: taskId,
expiresIn: '7d' // or expiresAt: timestamp
})
// Revoke a grant (last-admin protection prevents locking yourself out)
await store.auth.revoke({ grantId })
// List all grants for a node
const grants = await store.auth.listGrants({ nodeId: taskId })

Grants are stored as signed nodes in the NodeStore — they sync, replicate, and benefit from the same CRDT conflict resolution as all other data.

Any user with the share action can delegate their access to another user via UCAN proof chains. Delegation is attenuated — you can only delegate up to (not beyond) your own permissions:

// Alice (owner) grants Bob write access
await store.auth.grant({ to: bob, actions: ['write'], resource: docId })
// Bob can delegate read access to Carol (subset of Bob's write)
await store.auth.grant({
to: carol,
actions: ['read'],
resource: docId
// Bob's grant to Alice is automatically included as proof
})

Delegation chains are capped at depth 4 (maxProofDepth = 4). Revoking a parent grant cascades to all derived grants.

Grants with expiresIn or expiresAt automatically become inactive after their expiry time. A background GrantExpirationCleaner prunes expired grants and re-encrypts affected nodes with updated recipient lists.

When you call store.create(), store.update(), or store.delete(), the store checks authorization first:

try {
await store.update(TaskSchema, taskId, { title: 'New title' })
} catch (err) {
if (err instanceof PermissionError) {
// err.message: "denied: action=write, subject=did:key:..., role context: [viewer]"
}
}

When a remote peer’s change arrives via sync, the store silently rejects unauthorized changes and emits a change:rejected event. The peer also receives a scoring penalty to protect against abuse.

Collaborative editing (via useNode) uses a layered auth model:

  1. Room gate — only authorized users can join a sync room
  2. Per-update gate — each incoming Yjs update is checked against PolicyEvaluator
  3. At-rest encryption — Y.Doc state is encrypted with the node’s content key

Revoking a user from a document immediately kicks them from the room and rotates the content key, so all future updates are encrypted for the new recipient set.

The hub never decrypts data — it filters queries by recipient lists stored in node metadata. If your DID isn’t in the recipients list, the hub doesn’t return the node. This is transparent to your app: you write normal queries, unauthorized data is silently excluded.

ContextWhen auth failsWhy
store.create/update/deleteThrows PermissionErrorDeveloper should see and handle errors
applyRemoteChangeSilently rejected + change:rejected eventDon’t crash on bad remote data
Yjs updateSilently rejected + peer scoring penaltyDon’t interrupt editing flow
Hub queryNode excluded from results (no error)Transparent access control

Read access is cryptographically enforced. If you don’t have the content key, you cannot decrypt the data. The hub can’t help you because it doesn’t hold keys.

Write access is enforced client-side. A malicious client could skip the can() check and broadcast a signed change. The hub and honest peers accept it because the Ed25519 signature is valid.

This is an intentional tradeoff:

  • All changes are signed and part of a tamper-evident hash chain — unauthorized writes are attributable and detectable
  • Peer scoring penalizes unauthorized write attempts, leading to automatic blocking
  • Key rotation on revocation means revoked users lose future read access regardless of write-side enforcement

For apps where write enforcement must be stronger, self-hosted hubs can add custom policy validation.

When encrypting a node for a recipient, xNet needs their X25519 public key to perform key wrapping. It resolves this in two steps:

  1. Birational conversion — Ed25519 public keys (from DID:key) can be mathematically converted to X25519 keys without any additional lookup. This works for any DID:key holder.
  2. Hub registry fallback — For cases where the key holder has registered a separate X25519 key (unusual), the hub maintains a public key registry.

This means sharing data with any DID:key holder requires no out-of-band key exchange.

Your signing key can be derived deterministically from a seed phrase (BIP-39 format). The seed phrase is the root of your identity — back it up carefully.

import { deriveSeedPhrase, recoverFromSeedPhrase } from '@xnetjs/identity'
// Generate a new identity with a recoverable seed
const { mnemonic, bundle } = deriveSeedPhrase()
// mnemonic: "correct horse battery staple ..."
// Recover identity on a new device
const recoveredBundle = recoverFromSeedPhrase(mnemonic)
// Produces the same DID and key pair

Each device derives the same primary key from the seed, so your DID is the same across devices. When you add a second device:

  1. The new device generates its own device-specific encryption key
  2. It registers in a device registry (synchronized via the Hub)
  3. Your primary key wraps the content keys for each device

As a fallback when the seed phrase is unavailable, xNet supports Shamir’s Secret Sharing: split the seed across trusted contacts, any threshold (e.g., 3 of 5) can reconstruct it.

The hub can store an encrypted backup of your key bundle, unlocked by a passphrase or WebAuthn credential. This is opt-in and the hub never sees the plaintext key.

By default, authorization decisions are cached with a 5-minute TTL. This means:

  • The app works offline — cached decisions are used while disconnected
  • Revocations take at most 5 minutes to propagate to offline users
  • On reconnect, the cache is revalidated against the current grant state

Configure offline behavior per-schema:

authorization: {
// ...
offlinePolicy: {
cacheTTL: 300_000, // 5 minutes (default)
maxStaleness: 3_600_000, // 1 hour — refuse to use older cached decisions
revalidateOnReconnect: true // (default) revalidate immediately when online
}
}

Schemas without an authorization block continue to work but emit a console warning in development. To add authorization to an existing schema:

  1. Add the authorization block with your desired roles and actions
  2. Run the AuthMigrator utility to batch-encrypt existing nodes:
import { AuthMigrator } from '@xnetjs/data/auth'
const migrator = new AuthMigrator(store, encryptionLayer)
await migrator.migrateSchema(MySchema, {
batchSize: 50,
onProgress: ({ done, total }) => console.log(`${done}/${total}`)
})

The migration re-encrypts nodes in batches and updates their recipient lists. Existing peers will receive the re-encrypted versions via normal sync.