Skip to content

Authentication

Every credential travels inside the sealed envelope, so no proxy in the path sees it. A client holds one credential at a time: a session or an API key.

import { browserPasskey } from '@calimero-network/mero-js';
const passkey = browserPasskey(); // Touch ID, Windows Hello, a security key, a synced passkey
await client.signUp(passkey, 'alice'); // new account and passkey: asks the passkey twice
await client.signIn(passkey); // existing passkey

A sign-up prompts twice: once to register the new passkey, once for that passkey to sign the account it creates. A registration carries no signature by the new key, so the cluster takes the account only with that second assertion (the node’s 412 CONFIRM_REQUIRED, which signUp answers).

browserPasskey() wraps navigator.credentials. Anything that implements PasskeyProvider (create(options) and get(challenge, rpId, allowCredentials?), answering base64url fields) works in its place, for example a software authenticator in tests.

Each passkey is a device of the account. Adding or removing one is approved with a passkey the account already has (the new one cannot approve itself):

const credentialId = await client.addPasskey(newDevicePasskey, passkey); // another device, approved by `passkey`
await client.labelPasskey(credentialId, 'work laptop');
await client.removePasskey(credentialId, passkey); // never the last one

An account and its passkeys belong to one cluster. Clusters may share a WebAuthn relying-party id (every Calimero Cloud cluster uses calimero.network), so the browser also offers a passkey made on one cluster when a page signs in on another. That sign-in fails with PasskeyUnknownHere (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”. A removed passkey gets the same answer, and the node never says which cluster or account a passkey is from: it knows only its own.

To tell them apart, the node names each passkey after its cluster: a password manager lists alice · eu-shared-1, or alice · kv-store (eu-shared-1) for one made by signing up on an app’s frontend.

import { PasskeyUnknownHere } from '@calimero-network/mero-js';
try {
await client.signIn(passkey);
} catch (e) {
if (e instanceof PasskeyUnknownHere) showSignUpOrRightClusterLink();
else throw e;
}

The client does not refresh for this error (it is not LoginRequired), and trying the same passkey again fails the same way.

A package’s frontend served by the cluster signs people in on the cluster’s origin. To host it yourself (https://chat.example.com), a manager of the package registers that origin, approved with their passkey:

await client.packageOrigins.add('com.example.chat', 'https://chat.example.com', passkey);
// { package, origin, rpId: 'chat.example.com', addedBy, addedAt }; rpId defaults to the host
await client.packageOrigins.list('com.example.chat');
await client.packageOrigins.remove('com.example.chat', 'https://chat.example.com', passkey);

To have the cluster serve the package’s frontend on that name too (a custom domain, from the signed bundle), add it with served: true:

await client.packageOrigins.add('com.example.chat', 'https://chat.example.com', passkey, { served: true });

The record is the cluster’s half: browsers reach the name once Calimero Cloud holds a certificate and a route for it.

On that site, signUp and signIn work as anywhere else: in a browser whose window.location.origin is not one of the client’s nodes, they send it, and the passkey is made for (and asserted with) the relying-party id the node answers for it. Pass { origin } to name it yourself, or { origin: null } to send none. The session is bound to the package, as on an app’s own frontend; me() answers its app and origin, and its passkey confirmations are signed for the same relying-party id.

try {
await client.signIn(browserPasskey());
} catch (e) {
if (e instanceof OriginNotRegistered) showError(e.message); // "this site (https://…) is not registered for …"
else throw e;
}

Removing the origin ends the sessions opened there: their next request is refused 401 (LoginRequired).

signUp and signIn answer a CalimeroSession ({ accountId, sessionToken, expiresAt, refreshToken }) and use it for every request after. Keep it to resume without a passkey prompt:

const session = await client.signIn(passkey);
sessionStorage.setItem('calimero', JSON.stringify(session));
// another page load
const resumed = new CalimeroClient({ baseUrl, policy });
resumed.useSession(JSON.parse(sessionStorage.getItem('calimero')!));
await resumed.refresh(); // swap the refresh token for a new session

A refresh token is used once: a reused one revokes the session. A session is signed by the node that issued it, so it can stop verifying when that node is replaced or cannot be reached; the client then refreshes by itself, once, on any node, and repeats the request (see Restarts and other nodes). A session that refreshing cannot renew — expired, revoked, or a refresh token already used — makes requests fail with LoginRequired.

Keep client.session after requests, not only after signIn: an automatic refresh replaces it, and the refresh token you stored before is then spent.

await client.signOut(); // this session
await client.signOutEverywhere(); // every session of the account, on every device

Agents and servers authenticate with an API key (cal_...), scoped and revocable. Creating one is approved with the account’s passkey, from a passkey session (an API key cannot mint another). The key is returned once.

const { keyId, key } = await client.createApiKey(
{
label: 'ci',
apps: [appId], // only these apps
methods: ['get'], // only these methods
readOnly: true,
ttlSecs: 86_400,
},
passkey,
);
const agent = new CalimeroClient({ baseUrl, policy });
agent.useApiKey(key);
await client.listApiKeys(); // never the keys themselves
await client.revokeApiKey(keyId, passkey); // approved with the passkey

An upgrade with a session needs a passkey every time, which a CI job does not have. An upgrade key is made once, with the passkey, for one app: it upgrades that app to signed releases of its package (by the package’s pinned signer) with no passkey, and the node refuses it everything else, reads included (403 UPGRADE_KEY_ONLY). The approval gates still apply: who may upgrade the app, release deadlines and deployer grants.

// Once, by the app's owner:
const { key } = await client.createApiKey(
{ label: 'ci', upgrades: { app: appId }, ttlSecs: 90 * 86_400 },
passkey,
);
// In CI, after publishing 1.4.0 to the registry:
const ci = new CalimeroClient({ baseUrl, policy });
ci.useApiKey(key);
await ci.upgradeApp(appId, { version: '1.4.0' });

Revoke it with revokeApiKey; the app’s lastUpgrade names the key that made each upgrade.

Some requests need the account holder’s passkey over that exact request: a method the app declares confirm, deleting an organization, upgrades, transfers and approvals, and every grant of access: adding or removing a passkey, creating an API key, setting or removing an organization or team member, capabilities and invites. The node answers 412 CONFIRM_REQUIRED with a challenge; the *Confirmed methods, and every method that takes a passkey, sign it and send the request again.

await client.callConfirmed(app, 'clear', {}, passkey);
await client.postConfirmed(`orgs/${org}/outbound`, { package: 'com.example.kv' }, passkey);

A prompt the user dismisses rejects with ConfirmCancelled, and nothing more is sent. An API key can answer a confirmation only if it was created with allowConfirm.

Someone who loses every passkey cannot sign in, and nobody can act for them. A recovery code, made with a passkey and shown once, is the way back (core #4844):

const code = await client.createRecoveryCode(passkey); // show it once; keep it offline
// Every passkey lost, signed out: the code and a new passkey start a recovery.
const { accountId, readyAt } = await fresh.startAccountRecovery(code, browserPasskey());
// 72 hours later (until then, any passkey the account still has may cancel it):
await fresh.finishAccountRecovery(browserPasskey(), accountId); // signed in; other sessions end

A signed-in client sees a recovery under way in me().recoveryPending and cancels it with cancelAccountRecovery(passkey). A code starts one recovery and is spent whether it finishes or is cancelled; a wrong or spent one is WrongRecoveryCode, finishing early RecoveryNotReady (with readyAt).