Authorization
The core insight
Section titled “The core insight”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.
Defining authorization in a schema
Section titled “Defining authorization in a schema”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 }})Role types
Section titled “Role types”| Builder | Resolves 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 |
Actions
Section titled “Actions”| Action | Covers |
|---|---|
read | store.get, useQuery, hub queries |
write | store.create, store.update, store.restore |
delete | store.delete |
share | store.auth.grant, store.auth.revoke |
admin | Hub operational controls |
Explicit deny() always wins over allow():
actions: { read: allow('viewer').deny('blocked'), // 'blocked' role can never read}Public nodes
Section titled “Public nodes”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})Permission presets
Section titled “Permission presets”import { presets } from '@xnetjs/data/auth'
// Full owner control — only creator can do anythingauthorization: presets.ownerOnly
// Owner + share — owner shares explicit read/write with others via grantsauthorization: presets.ownerWithShare
// Collaborative — creator is owner, any member of a 'workspace' relation can read/writeauthorization: presets.collaborative({ relation: 'workspace' })
// Fully public — all content is unencrypted and anyone can readauthorization: presets.publicChecking permissions
Section titled “Checking permissions”useCan hook
Section titled “useCan hook”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.
store.auth.can()
Section titled “store.auth.can()”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' }Explaining a decision
Section titled “Explaining a decision”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' }// ]// }Granting and revoking access
Section titled “Granting and revoking access”useGrants hook
Section titled “useGrants hook”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> )}store.auth API
Section titled “store.auth API”// Grant access with optional expiryawait 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 nodeconst 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.
Delegation (UCAN chains)
Section titled “Delegation (UCAN chains)”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 accessawait 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.
Grant expiration
Section titled “Grant expiration”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.
How enforcement works
Section titled “How enforcement works”Local mutations
Section titled “Local mutations”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]" }}Remote changes
Section titled “Remote changes”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.
Yjs documents
Section titled “Yjs documents”Collaborative editing (via useNode) uses a layered auth model:
- Room gate — only authorized users can join a sync room
- Per-update gate — each incoming Yjs update is checked against
PolicyEvaluator - 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.
Hub filtering
Section titled “Hub filtering”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.
Failure modes
Section titled “Failure modes”| Context | When auth fails | Why |
|---|---|---|
store.create/update/delete | Throws PermissionError | Developer should see and handle errors |
applyRemoteChange | Silently rejected + change:rejected event | Don’t crash on bad remote data |
| Yjs update | Silently rejected + peer scoring penalty | Don’t interrupt editing flow |
| Hub query | Node excluded from results (no error) | Transparent access control |
Trust model
Section titled “Trust model”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.
Key resolution
Section titled “Key resolution”When encrypting a node for a recipient, xNet needs their X25519 public key to perform key wrapping. It resolves this in two steps:
- 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.
- 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.
Key recovery & multi-device
Section titled “Key recovery & multi-device”Seed phrase
Section titled “Seed phrase”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 seedconst { mnemonic, bundle } = deriveSeedPhrase()// mnemonic: "correct horse battery staple ..."
// Recover identity on a new deviceconst recoveredBundle = recoverFromSeedPhrase(mnemonic)// Produces the same DID and key pairMulti-device
Section titled “Multi-device”Each device derives the same primary key from the seed, so your DID is the same across devices. When you add a second device:
- The new device generates its own device-specific encryption key
- It registers in a device registry (synchronized via the Hub)
- Your primary key wraps the content keys for each device
Social recovery
Section titled “Social recovery”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.
Encrypted hub backup
Section titled “Encrypted hub backup”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.
Offline authorization
Section titled “Offline authorization”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 }}Migration from unencrypted schemas
Section titled “Migration from unencrypted schemas”Schemas without an authorization block continue to work but emit a console warning in development. To add authorization to an existing schema:
- Add the
authorizationblock with your desired roles and actions - Run the
AuthMigratorutility 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.
Related
Section titled “Related”- defineSchema — Full schema API reference
- Identity & Keys — DID:key, UCAN tokens, key management
- Cryptography — BLAKE3, Ed25519, XChaCha20, X25519
- Offline Patterns — Offline cache behavior and sync
- DevTools — AuthZ panel for live inspection