Skip to content

Accounts, passkeys & sessions

A person is an account. They prove it with a WebAuthn passkey, and each passkey on the account is one of their devices. Signing in gives them a short session token that every node of the cluster verifies on its own. A machine acting for an account uses an API key. calimero-identity holds the logic (WebAuthn, tokens, challenges) with no storage of its own; the records live in the system group.

An account id is 32 random bytes in base64url, made when someone signs up. The account record holds a display name, its passkeys (at most 10) and their labels, the account’s aliases for apps, and the approvals it makes for apps it owns outside any organization (mero-tee releases and outbound prefixes; see Deployment governance). Who may sign up is the cluster’s sign-up policy: open (the default; the operator’s maxAccounts quota bounds how many accounts a cluster takes), invite or closed. CreateAccount carries the invite a person signs up with, as the token key’s signature over the new account and the time, and apply judges it against the policy.

The release of the node an account signed up through is approved for that account’s own apps from the start, so its apps can run where it began.

calimero-identity::webauthn verifies registrations and assertions itself (ES256 and EdDSA, via ring). Attestation statements are not checked (passkeys register with attestation: none); the relying party id is --rp-id and the accepted origins are --origins, plus a package’s frontend origin for a ceremony that comes from it. A ceremony from an origin a package’s manager registered (below) is checked against that origin alone and its own relying party id.

Ceremony Routes Result
Sign up /auth/passkey/register/{begin,finish} A new account with this passkey, and a session
Sign in /auth/passkey/login/{begin,finish} A session for the passkey’s account
Add a device /auth/passkeys/add/{begin,finish} (signed in; finish answers 412 and is approved by a passkey the account already has) Another passkey on the account
Recover (lost every passkey) /auth/recovery/{begin,start} (signed out; the account’s recovery code and a new passkey), then after 72 hours /auth/recovery/finish (the new passkey; /auth/recovery/cancel with an existing passkey stops it before) The code spent and the passkey pending; at finish the passkey on the account, every earlier session revoked, and a session
Label or remove a device POST / DELETE /auth/passkeys/{cred} (removal approved with a passkey, 412 first) A label; or the passkey gone and its sessions revoked (the last passkey cannot be removed)

A ceremony’s challenge lives in the memory of the node that issued it, for 5 minutes; its id names that node. It may finish on any node: that node takes the challenge from the issuer over the attested peer channel, so it is still used once (a load balancer may send the two requests anywhere). A node holds at most 10 000 open ceremonies; past that it answers 429.

Accounts and passkeys live in one cluster’s system group, while clusters may share a relying party id (every Calimero Cloud cluster uses calimero.network). A browser therefore offers a passkey made on one cluster on every other one. Two things keep that from confusing anyone:

  • The name it is made under. A registration’s user.name and user.displayName (sign-up, adding a device, recovery) are the person’s name and the cluster’s (--cluster) with its tag, alice · eu-shared-1 · 3f2a; a sign-up begun on a package’s frontend (a served or registered origin, origin on register/begin) names the app too, alice · kv-store (eu-shared-1 · 3f2a): the newest release’s display name, else the package id. Each part is cut at 32 characters (MAX_PASSKEY_NAME_PART_CHARS). The account’s own name is the person’s alone. The tag is the first 2 bytes of the cluster’s identity key (from its cluster key), 4 hex characters, public in GET /sign-up (passkeyTag, beside cluster): a cluster begun again under the same name has another cluster key, so its passkeys are listed apart from the old cluster’s. Each registration gets a random user handle (sign-up, adding a device and recovery alike): a password manager keeps one passkey per site and handle, so a shared handle would make a second passkey added in the same manager replace the first.
  • The answer to a passkey from elsewhere. An assertion (sign-in or step-up) whose credential no account here has answers 401 PASSKEY_UNKNOWN_HERE, “this passkey belongs to another Calimero cluster or was removed from this one; sign up here, or open the app from the link for the cluster your account is on”. It names no cluster and no account: a node knows only its own cluster’s passkeys, and the answer is the same for a passkey that was removed.

Sign-in and sign-up need the user present. A step-up — approving a confirm method, an upgrade, an approval or an organization’s deletion — needs the user verified too (PIN or biometric): a touch alone is not consent.

A session token is v2.<claims>.<Ed25519 signature>, signed by the node that issued it with its signing key (the key it signs receipts with, attested with GET /attestation?purpose=receipts) over "calimero.session.v2\0" ‖ claims. A node makes that key on its first start and keeps it in its data directory wrapped under its storage key K_r, so a restart on the same data directory (a crash, a rolling upgrade of the binary) keeps it. The claims are the account, a session id, the expiry (15 minutes), the passkey the session was opened with, for a session opened on a package’s frontend that package (and, on a registered origin, that origin), and the issuing node with its key. Signing in also returns a refresh token (rt_…, 30 days): POST /auth/refresh on any node answers a new token, signed by that node. A refresh token younger than an hour (api::REFRESH_ROTATE_AFTER) is kept: the node judges it on the cluster’s latest system state (a read index) and writes nothing to the system log, so a sign-out, a recovery or a passkey removal committed through any node already ends it. An older one is exchanged in the system log for a new refresh token; a refresh token presented again after it was exchanged revokes its session. So every active session writes to the system log once an hour, not once per session token.

A node verifies a token issued elsewhere without asking the issuer: it checks the signature against the issuer’s signing key as it attested it at the address the cluster registered for that node, checks the expiry, and that the issuer’s attested release may vouch for the account: a release the account approved for its own apps or an organization it belongs to approved (where the operator holds release approvals, one it approved). Each app access then also requires the issuer’s release to be approved for that app’s data. So a node on a release nobody approved can mint tokens, but no other node takes them. Then — on fresh system state — the node checks that the session is not revoked and that its passkey still belongs to the account. See Keys & their lifetimes for revocation.

Every node attests each registered peer’s signing key ahead of time: at start, once it learns the directory, and whenever a node is registered. It keeps node → (key, release, attested at) in memory (Node::warm_issuers, at most MAX_CONCURRENT_ISSUER_ATTESTS at once, each bounded by ISSUER_ATTEST_WAIT). A token is verified from that entry without reaching the issuer, so a session outlives the node that issued it: with the issuer stopped or frozen, any other node still takes it.

  • The issuer restarted on its data directory: same key; its sessions go on verifying everywhere.
  • A token under another key than the one attested (the issuer’s data directory was replaced): the node attests the issuer again, at most every ISSUER_RETRY_SECS. If the issuer cannot be reached, or attests another key, the token is refused with 401.
  • The issuer was removed: its entry is dropped and its tokens stop verifying once the removal applies.

On a 401 a client refreshes once, on any node, and sends the request again (the Rust client and meroctl do it on their own).

The key is wrapped under K_r, not K_c. K_r comes only to the release whose mero-kms holds its root, so only that release opens the key: a key attested once under release R signs only for R, and a node on another release booted on the same disk cannot sign as this one.

A node on a release the account’s owners have not approved neither issues nor takes the account’s sessions: it answers 421 WRONG_NODE, and the client uses another node.

There are no device keys. A device is a passkey; /auth/me reports which one the current session was opened with (passkeyId), when each was added and last signed in with (addedAt, lastSignInAt), and removing a passkey ends that device’s sessions. GET /auth/sessions lists the account’s sessions, the passkey each was opened with and the package and origin it is bound to, and DELETE /auth/sessions/{session} ends one (Sessions). GET /auth/activity lists what each passkey did, the last 100 entries: the package and origin each sign-in was bound to, and the method and subject of each change it approved (Activity). These are the account holder’s alone: the times come from the log entries a sign-up, a sign-in, an added passkey and a recovery already write (each carries its time; a refresh writes none), and no route shows them to operators, organization admins or deployment managers. Inside an app, env::caller() is the account, whichever of its devices made the call (Who a run acts as). Presence keeps one entry per device: the session’s passkey, or the API key.

POST /auth/apikeys { label, apps?, methods?, readOnly?, allowConfirm?, ttlSecs? } creates a key cal_… for the signed-in account, approved with a passkey (412 first; the consent binds the key’s hash and record); only its hash is stored and the key is shown once. A key may be scoped:

  • apps: only these app instances (default: every app the account reaches);
  • methods: only these methods (* for any);
  • readOnly: views, events and reads only;
  • allowConfirm: may carry the account holder’s passkey assertion for a confirm method (it can never produce one);
  • ttlSecs: lifetime, at most and by default one year.
  • upgrades: { app, package? }: an upgrade key, which takes none of the fields above but ttlSecs.

A deploy from CI cannot answer a passkey step-up. An upgrade key is an API key whose scope is one app’s upgrades: POST /auth/apikeys { label, ttlSecs?, upgrades: { app, package? } }, approved with the passkey of someone who may upgrade the app. Its record keeps the app, the package (by default the one the app runs) and that package’s pinned signer, and the public half of an Ed25519 key derived from the key itself (proof::api_key_public: seed SHA-256("calimero.api-key.v1\0" || key)); the passkey consent covers all four.

  • What it does: POST /apps/{app}/upgrade { version } for its app, with no step-up, to a release of its package installed under the signer it names (a registry release is installed first, as for a session). The node signs the upgrade’s statement (<app> / __upgrade / { bundle, migrate, manifest }) with the derived key, and the directory’s UpgradeApp carries that signature in place of a passkey consent. Every replica checks it where it applies, against the key as the log holds it then, and spends it once; a node that saw only the log cannot sign one.
  • What it does not: every other route, reads included, answers 403 UPGRADE_KEY_ONLY. A raw bundle, another package, another signer and another app are refused 403.
  • The rules that still apply: governance holding app upgrades for owners, the key’s account still may upgrade the app (a member who lost MANAGE_APPLICATION takes the key’s power with them), an owner release is never moved to a publisher one, release deadlines, and deployer grants for a registry install.
  • Revoking it (DELETE /auth/apikeys/{key}, approved with a passkey) stops it on every node at once, for an upgrade logged later too. It also ends with its ttlSecs.
  • What is logged: the app’s lastUpgrade names the account and the key (apiKey), in GET /auth/apps and GET /orgs/{org}/apps; the node logs each such upgrade with the app, the key’s id and the bundle.
Action Passkey session Package-bound session Unscoped API key Scoped API key
Call and read apps it reaches ✓ that package’s apps only ✓ within scope
Install packages, create apps ✓ instances of its own package only ✓ —
Manage passkeys, API keys, approvals, upgrades, uninstalls ✓ — — —
Upgrade one app to signed releases of its package, without a step-up — — — an upgrade key, and nothing else
Approve a confirm method ✓ (with step-up) from its own origin only with allowConfirm, relaying an assertion same
Ask for an approval on the node’s consent page ✓ ✓ (returns to its own origin) — —

Deleting an app, changing its access, creating organizations and issuing invites also need a passkey session: an API key cannot do them.

A session opened on a package’s frontend is bound to that package (SessionClaims::app, carried through refresh): it reaches only apps running a release of that package, and the node refuses it credential management, API keys, approvals, upgrades, uninstalls and uploads. An app’s page is third-party code; this keeps it from acting as the whole account.

A developer’s own site (their domain, a hosting preview) is not an origin the node serves, so on its own it could not run a passkey ceremony against the cluster. A package’s manager registers it (/package-origins/{package}): where the package’s signing key is bound to a publisher organization, that organization’s org-wide MANAGE_APPLICATION holders; otherwise the account that installed the package’s first release. Never the operator, an API key or a package-bound session.

Change Route Consent (412 first)
List GET /package-origins/{package} none
Register POST /package-origins/{package} { origin, rpId?, served? } package:{package} / __add_origin / { origin, rpId } ({ origin, rpId, served: true } for a served one)
Remove DELETE /package-origins/{package} { origin } package:{package} / __remove_origin / { origin }

The consent is logged with the command (AddPackageOrigin, RemovePackageOrigin) and checked where it applies, on every node: a command without the manager’s own consent over exactly that change is refused there, whichever node proposed it. The registrations live in the system state (SystemState::package_origins, crate::origins).

Relying party. WebAuthn binds a passkey to a relying-party id, which must be the page’s host or a parent domain of it. The cluster’s --rp-id cannot be a developer’s domain, so each registered origin carries its own rpId: the origin’s host by default, or a parent domain of it that the manager names (https://app.example.com with example.com). The cluster’s rp-id, or a parent of it, is refused, so the cluster’s own passkeys never answer on a developer’s site. A person signing in there makes a passkey for that site; the account it opens is an ordinary cluster account whose sessions from that origin are always bound to the package. WebAuthn’s Related Origin Requests (one rp-id, a /.well-known/webauthn list of origins) was not chosen: browsers allow the list five registrable domains, for the whole cluster; the rp-id’s host would have to serve the list, which the node does not control; and not every browser supports it.

Ceremonies. POST /auth/passkey/{register,login}/begin take the page’s origin and answer its rpId (403 ORIGIN_NOT_REGISTERED for an origin that is neither the cluster’s, a node’s own, a served frontend’s nor registered). The finish routes verify a ceremony from a registered origin against { rpId, [origin] } alone, and open a session bound to the package exactly as a served frontend’s (SessionClaims::app), which also names the origin (SessionClaims::origin, carried through refresh; an exchange that changes it is refused). A step-up of that session answers 412 with the origin’s rpId.

A sign-up there is a sign-up like any other: the cluster’s sign-up policy judges it (invite on register/begin and register/finish, 403 SIGN_UP_REFUSED, checked again at apply), and the new passkey signs the account it creates (register/finish answers 412). That 412 names the origin’s rpId, as the new passkey belongs to it, and the confirmation is taken from that origin only.

Removal ends it: from the next request every session opened on the origin, and its refresh token, is refused 401, judged on fresh system state. An uninstalled package takes its origins with it.

Limits and rules. https://host[:port] exactly as a browser writes it (lowercase, no path, no default port); http:// only for a localhost name and only on a --dev node. One package per origin (first come; the cluster’s own origins and served frontends are refused). 8 origins per package, 4096 per cluster. CORS on the node’s API admits registered origins. The node logs, at info, each registration and removal (package, origin, rp-id) and each session opened on a registered origin (package and origin); who made the change is in the system log’s command and at debug only, never in the journal an operator reads.

Served origins (custom domains). Registered with served: true, the origin is one the cluster also serves the package’s frontend on, from its signed bundle, as at {label}.D: the developer points their domain at the cluster, and a request whose Host is that name (on 443) gets the package’s site before any node route. Everything else is a registered origin’s: its own rp-id, package-bound sessions naming it, removal ending them and the site with them. Its consent names served: true, so a consent to the plain origin never makes it served, and one origin is held either way (change it by removing it first). Only https://host with no port (http://name.localhost:port on a --dev node); not on a node without --apps-domain (400 NOT_SERVING), not under the apps domain or the cluster’s rp-id, not a node’s API host; 4 per package and 64 per cluster; adding one needs every node on this release (409 CLUSTER_NOT_UPGRADED). The operator lists them, by the package’s label, at GET /ops/served-origins, from which Calimero Cloud orders each one’s TLS certificate for the cluster’s attested nodes and routes the name. A custom domain’s sessions are only as safe as its DNS: whoever controls the name can serve their own page there and use its passkeys for that package. Design: docs/specs/custom-domains.md.

Not yet: proof that the manager controls the origin (a squatter on another developer’s origin denies them its use on this cluster, but never sees a token: the sessions go to the pages on that origin), and origins scoped to a deployment.

meroctl login --dev-passkey FILE keeps a software passkey in a file (SoftAuthenticator, feature testing). It is a real WebAuthn credential, so only a development cluster whose relying party accepts it can use it. Against a real cluster, people use their own authenticators in a browser and give programs API keys.