Tickets explained
lofi-node uses two kinds of ticket, and confusing them is the most common early stumble. This guide is the conceptual tour; the app-ticket contract is the normative spec the lofi side implements against.
Two ticket kinds
- A node-pairing ticket (
endpoint…) is an iroh endpoint address. It names a node so another node can dial it. It is used bylofi-node pairand never by browsers or apps. - An app-connect ticket (
lofisync1.…) is the credential you hand to a lofi app so it syncs against your node. It carries the store's app id and the gate URL with a 256-bit secret embedded in its path.
Why the secret lives in the URL path
A header was the obvious alternative and it does not survive contact with the platform: the
browser's WebSocket API cannot set request headers, so a header-borne credential would need a
different authentication path for exactly the connection that matters most. Jazz clients preserve a
base path in their server URL and reject query parameters. By making the ticket's URL
http(s)://host:port/t/<secret>, every request the client ever makes — sync WebSocket connects,
catalogue reads, admin calls — carries the secret with zero client changes. The cost of the
choice is that the secret rides in a URL, which is why proxy access logs need
redaction and why the gate compares digests rather than logging paths. The gate
validates the secret (timing-safe, digest against digest), strips the prefix, and proxies to the
loopback-only Jazz server. The lofi app uses the ticket URL verbatim as its sync server; that is the
whole integration.
Scopes
sync(the default, and what an absent scope means — every pre-scope ticket keeps meaning transport-only): sync, catalogue reads that transport needs, and the metadata-onlystore-statuspreflight. Admin routes answer the same401 {"error":"invalid_ticket"}as an unknown secret, so a probe learns nothing.provision: a strict superset — everything above plus store administration. The gate injects the node's ownX-Jazz-Admin-Secretfor provisioning requests and strips any inbound one, so the admin secret never transits the client. Possession of a provision ticket is the store-administration opt-in; issue one per provisioning context, not per device.
A ticket with an unrecognized scope is rejected outright by the lofi parser — never silently granted less than it claims.
Revocation
- The node stores only SHA-256 digests; the ticket string is displayable once, at issuance.
- Unknown and revoked secrets are indistinguishable to probers: both get
401 {"error":"invalid_ticket"}, on HTTP and on the WebSocket upgrade. - Revoking a ticket mid-session closes its live WebSockets with close code 4001 within a couple of seconds. The app treats the stored sink as dead and surfaces re-enrollment — it never silently retries forever.
The security posture, honestly
An app ticket starts as a bearer credential with 256-bit entropy. Anyone holding it can sync as an authorized transport peer; identity and row-level permissions remain Jazz's local-first layer on top, enforced by the deployed policy, not by the ticket. Plain http is acceptable on a trusted LAN; beyond one, front the gate with TLS — installed PWAs generally require a secure origin anyway. Issue tickets per device or context so revocation is scoped.
A derived sync ticket can additionally be bound to the enrolling device: the app offers a device public key at the scope-down exchange, and connecting then requires a fresh signature from that key — which never leaves the device — before the node accepts any traffic. A stolen ticket string alone no longer connects. What binding does not change: script running on the app's own origin can still drive the device key while the page is open, and the row-level permission story is still Jazz's. Binding defeats exfiltration of the credential, not live abuse of a compromised page.
What the app does with it
You paste; the app does the rest. The ticket is validated (against the same rules this node
enforces; the two repos share machine-readable conformance fixtures), declared as the device's sync
location, and sync is elected in one step. The app keeps the ticket URL in a sealed device-local
record (encrypted at rest under a device-bound key) and never surfaces it through its session
snapshot — only the host and your label show. A provision ticket gets split first: the app asks the
node for a derived sync ticket to store, and the provision original is either sealed behind your
passkey or kept only in memory with your password manager holding the durable copy. The derived
ticket is linked to its parent — revoking the provision ticket revokes it too. For app developers,
the framework call behind that paste is enrollSyncTicket, documented in
Sync and recovery.