Skip to content

Authentication

There is no separate auth service: the node itself registers passkeys, mints sessions and checks API keys (crates/node/src/api.rs, with the primitives in calimero-identity). Accounts, passkeys, API-key hashes, refresh tokens and revocations live in the cluster’s system group, and each node attests every peer’s session-signing key ahead of time, so any node verifies any credential.

Who Credential Lifetime
A person A WebAuthn passkey, which opens a session session 15 minutes, refresh token 30 days
A service An API key, cal_… ttlSecs, at most and by default 365 days
The operator The ops token until the configuration changes

All three travel as Authorization: Bearer … inside the sealed channel. A credential is judged on the cluster’s latest system state (a read index), so a revocation committed through one node holds on every node from the next request.

An account is created by a passkey registration and has one or more passkeys, each a device of the account.

  • Relying party. --rp-id (default localhost) is the WebAuthn relying party id; passkeys are bound to it. Set it to the domain your users’ browsers sign in on, and do not change it: existing passkeys stop working.
  • Origins. --origins (default http://localhost:3000) are the origins a ceremony may come from, and the browser origins allowed by CORS. Origins under --apps-domain are allowed too; see app-bound sessions.
  • Algorithms. ES256 and EdDSA; attestation none. A sign counter that does not advance is refused unless both values are zero.
  • Ceremonies. begin returns a challengeId valid for 5 minutes. The challenge is held in the memory of the node that issued it, so finish must reach the same node; a load balancer in front of the cluster needs session affinity for /auth/passkey/* and /auth/passkeys/add/*, or clients must talk to one node URL. A node holds at most 10,000 open ceremonies and then answers 429 RATE_LIMITED.
  • Sign-up policy. Each cluster has one, set at genesis (--sign-up) and attested with its governance; see Sign-up policy. On an open cluster (the default) anyone who can reach it can register an account, and the maxAccounts quota (default 100,000) bounds the total; see quotas.
Policy Who may create an account
open (default) Anyone who reaches the cluster.
invite Someone holding a valid organization invite (the new account joins the organization) or a cluster sign-up invite.
closed Someone holding a cluster sign-up invite only: the operator’s (or owner’s) pre-authorization. Organization invites still let existing accounts join.

Read it before sign-up, without a session: GET /sign-up answers { policy, orgInvites, signUpInvites, inviteRequired }. The attested governance carries it too (signUp, left out when open), on GET /attestation?purpose=receipts and GET /releases.

Sign up with an invite by sending its token on both halves of the ceremony:

POST /auth/passkey/register/begin { "name": "Ada", "invite": "<token>" }
POST /auth/passkey/register/finish { "challengeId": "...", "registration": {...}, "invite": "<token>" }

begin refuses a sign-up the policy would refuse with 403 SIGN_UP_REFUSED before any passkey is made, and so does finish, before it spends the challenge; the system log judges it again where it applies, so a leader cannot let anyone else in. An invite given on an open cluster is checked too (an unknown, expired or used one is refused, never ignored), and an organization invite there joins its organization. The node signs the invite’s redemption with the token’s key, as for /invites/{token}/redeem: the log never sees the token. With the Rust client: Client::sign_up_invited(passkey, name, Some(token)).

Who runs sign-up (its policy and its sign-up invites) is the party that runs the cluster’s deployers: the operator, with its key, until the operator names the cluster’s owner on a --deployers owner cluster; from then on the owner, with a passkey, and the operator’s signature is refused.

Change Operator (--ops-token, signed) Owner (passkey, 412 first) Statement
Set the policy POST /ops/sign-up { policy } POST /sign-up { policy } cluster / __sign_up / { policy }
Issue a sign-up invite POST /ops/sign-up/invites { token, uses?, expiresAt } POST /sign-up/invites { uses?, ttlSecs? } (answers the token) cluster / __create_sign_up_invite / { inviteKey, uses, expiresAt }
Withdraw one DELETE /ops/sign-up/invites/{inviteKey} (or POST .../revoke) DELETE /sign-up/invites/{inviteKey} cluster / __revoke_sign_up_invite / { inviteKey }
List them GET /ops/sign-up/invites GET /sign-up/invites

The operator chooses its token (16 to 256 bytes) so that the 428 OPERATOR_SIGNATURE_REQUIRED and the signed retry name the same invite; inviteKey is proof::invite_key(token), the Ed25519 public key whose seed is SHA-256("calimero.invite.v1\0" || token), and the list answers keys and terms only. A sign-up invite creates 1 to 10,000 accounts (default 1) and lives at most a year; at most 10,000 are live at once. Accounts it created stay when it is withdrawn, and a policy change never removes an account.

Devices: POST /auth/passkeys/add/* adds one (meroctl account device add), approved with a passkey the account already has; DELETE /auth/passkeys/{cred} removes one, approved with a passkey, and ends the sessions it opened. Both answer 412 CONFIRM_REQUIRED first, and every node checks the approval where the change applies.

Recovery (lost every passkey; design: docs/specs/account-recovery.md):

Route Who What
POST /auth/recovery signed in, unbound passkey session (412 first) Make a recovery code, shown once, replacing any earlier one; only its public key is stored
DELETE /auth/recovery signed in (412 first) Remove it
POST /auth/recovery/begin { code } signed out A passkey registration for the account the code names; 401 for a wrong or spent code, 409 RECOVERY_PENDING while one is under way
POST /auth/recovery/start { challengeId, code, registration } signed out Spend the code and log the new passkey as pending: { accountId, credentialId, startedAt, readyAt }. No passkey is added and no session opens. A wrong code answers 401 and spends the ceremony
POST /auth/recovery/cancel signed in with a passkey the account has (412 first) Cancel the recovery under way
POST /auth/recovery/finish { accountId? } signed out, the new passkey (412 first) Once readyAt passed (RECOVERY_DELAY_SECS, 72 hours, as for an organization): the passkey is added, every earlier session of the account ends, every API key of the account is revoked, and the answer is a new session. 409 RECOVERY_NOT_READY (with readyAt) before

/auth/me answers recoveryCode: true|false and recoveryPending ({ accountId, credentialId, startedAt, readyAt } or null), so every signed-in device can warn while a recovery is under way.

A successful ceremony answers { accountId, sessionToken, expiresAt, refreshToken }.

  • Session token: v2.<claims>.<Ed25519 signature>, valid 15 minutes, naming the account, the session, the passkey it was opened with and the node that issued and signed it. Other nodes take it only if that node’s attested release may serve the account (Sessions). A node on a release the account’s owners have not approved answers 421 WRONG_NODE instead of issuing or taking one: use another node.
  • Refresh token: rt_…, valid 30 days; POST /auth/refresh answers a new session and the refresh token to use next: the same one while it is younger than an hour (nothing is written to the system log), a new one after. Presenting a refresh token again after it was exchanged is recorded and ends the session.
  • Signing keys. Each node signs with its receipt key, made on its first start and kept in its data directory wrapped under K_r: a restart or a rolling upgrade of the binary keeps the sessions it issued, and they verify on every other node while it is down. A removed or replaced node’s sessions answer 401; clients refresh on any node.
  • Ending sessions: POST /auth/logout ends one; POST /auth/logout-all (meroctl logout --all) ends every session of the account; DELETE /auth/sessions/{session} (meroctl account session revoke) ends one of the account’s sessions from another (404 for any session the account does not hold live, another account’s included).
  • Listing sessions: GET /auth/sessions (meroctl account session list) answers the account’s live sessions, latest active first, at most 256: { session, passkey, app, origin, openedAt, lastActiveAt, current }. passkey is the device it was opened with, app and origin the package and registered origin it is bound to (null: the cluster’s own origin), openedAt when it opened (null: before the node kept it), and current marks the caller’s own. lastActiveAt is when its refresh token was last exchanged: a token younger than an hour refreshes without a write, and a session token lives 15 minutes, so the session may have been used up to about 1 h 15 min later. Both routes take the account holder’s own session on the cluster’s origin, as sign-out everywhere does, never an API key or a package-bound session (403), and ask no passkey: ending one session does less than ending all of them.
  • Passkey times: /auth/me gives each passkey addedAt and lastSignInAt (Unix seconds): a sign-up, a sign-in and a recovery’s finish move the last sign-in; a refresh does not. null means it happened on a release that kept no times (1.0.0-rc.14 and earlier). Only the account’s own credentials read them: no operator, organization or deployment route shows them.

GET /auth/activity (meroctl account activity) answers what the account’s passkeys did, newest first: { activity: [{ at, passkey, kind, app, origin, method, subject }] }. Every key is present; the ones an entry’s kind does not have are null.

  • kind: "signIn": a sign-up, a sign-in or a recovery’s finish with passkey, at at (Unix seconds). app and origin are the package and registered origin the session is bound to (null: the cluster’s own origin). A refresh is no sign-in, and a sign-in that repeats the newest entry (same passkey, app and origin) only moves its at.
  • kind: "approval": a change passkey approved with a step-up (412 CONFIRM_REQUIRED), with the method (__add_passkey, __create_api_key, __upgrade, __create_invite, …) and subject (an app id, org:<id>, account:<id>, …) its consent states; never its arguments. Approvals inside an app (an app’s own confirm methods) are the app’s, and are not listed.

The account keeps the last 100 entries; the oldest goes first. A removed passkey’s entries stay. ?after=T answers only entries after T. Entries are kept from 1.0.0-rc.15: what a release before did is not listed (its log entries carry no time, and replaying them adds nothing). The route takes the account holder’s own session on the cluster’s origin, as GET /auth/sessions does: never an API key or a package-bound session (403). No operator, organization or deployment route shows any entry.

Created by an account holder with a passkey session and approved with a passkey (POST /auth/apikeys, 412 CONFIRM_REQUIRED first; meroctl account api-key create); the key is shown once and only its hash is stored. An API key never creates another.

Field Meaning
label A name for it.
apps Only these apps. Absent: every app the account reaches.
methods Only these methods (* for any).
readOnly Refuse writes.
allowConfirm May call confirm methods; each call still needs the account holder’s passkey assertion. Without it such calls answer 403.
ttlSecs Lifetime; at most and by default 365 days.

A key cannot manage credentials (passkeys, other keys, sessions), approvals or upgrades: those need a passkey session. A key scoped to apps or methods, or read-only, also cannot install code or create apps. meroctl takes a key with --api-key or CALIMERO_API_KEY.

Some actions need a fresh passkey assertion over the exact request, which the log keeps as the owner’s consent:

  • calls of methods listed in the app’s confirmMethods;
  • app upgrades, release approvals and outbound approvals by an owner;
  • organization-wide upgrade runs and their retries;
  • deleting an organization.

The node first answers 412 CONFIRM_REQUIRED with a challenge in details; the client signs it with a passkey and retries with confirm: { challengeId, assertion }. Like a sign-in challenge, it is held by the node that issued it for 5 minutes, so the retry must reach the same node. Browsers and the SDK clients do this; meroctl does it with a file passkey (--dev-passkey) on development clusters.

With --apps-domain, a passkey ceremony run from a package’s frontend origin ({label}.{domain}, its cluster host {cluster_label}.{domain}, or a deployment’s host {dlabel}.{domain} offering it) opens a session bound to that package. It reaches only apps that run a release of that package, can create instances of that package only, and is refused credentials, API keys, approvals, upgrades, uninstalls and uploads. Its step-up confirmations come from its own origin only.

A package’s manager can also register origins of their own (a developer’s site): GET|POST|DELETE /package-origins/{package}, each change approved with their passkey. A ceremony there uses the origin’s own rp-id and opens the same package-bound session; removing the origin ends those sessions. Registered with served: true (a custom domain), the cluster also serves the package’s frontend there. See Registered origins.

The Ops API (/ops/*) takes the operator’s token as a bearer token.

  • --ops-token TOKEN gives the node the token itself.

  • --ops-token-sha256 HEX gives it only the token’s SHA-256, so the machine’s configuration and metadata hold nothing an operator can sign in with. Generate a token and its hash with, for example:

    Terminal window
    TOKEN=$(openssl rand -hex 32)
    printf %s "$TOKEN" | sha256sum | cut -d' ' -f1
  • With neither, every /ops/* route answers 403 (“the Ops API is disabled on this node”).

  • The comparison is constant-time. The token is the same for every node only if you configure it so; each node checks its own configuration.

The ops token is not an account: it never reads app state, and what the operator installs is recorded as the actor operator, which no account can be. What it may decide beyond counts and placement depends on the cluster’s governance.

merod local and --dev nodes accept mock attestation, so meroctl can keep a passkey in a file (--dev-passkey, a software authenticator for relying party localhost). meroctl login refuses a file passkey unless the node is trusted with --allow-mock. On a real cluster, sign in through a browser and give tools an API key.