Skip to content

Auth Service & Providers

A merod node’s HTTP server (admin API, JSON-RPC, WebSocket, SSE) can be protected either by an external proxy or by a bundled authentication service, mero-auth. This page describes the two modes, the JWT model the auth service uses, the providers it ships, and the local-development mock-token endpoint. For where auth fits into hardening a deployment, see Hardening & Security; for the auth_mode config key and init flags, see the config reference and merod reference.

The server.auth_mode setting picks how requests are authenticated.

The node does not authenticate requests itself. It expects an external, authenticating proxy in front of it that decides who gets through. This is the default. With no proxy in place, the server is effectively unauthenticated, so the loopback bind (see Hardening) matters.

The auth service issues JSON Web Tokens. The relevant details, grounded in the token manager:

  • Algorithm: HS256 (symmetric). Tokens are signed with a secret that the service generates and stores in its own storage backend — there is no external key file to manage.
  • Two token types: a short-lived access token and a longer-lived refresh token.
  • Default lifetimes: access token 3600 seconds (1 hour), refresh token 30 days. Configurable via jwt.access_token_expiry / jwt.refresh_token_expiry.
  • Application client keys get shorter tokens: access token 900 seconds (15 minutes), refresh token 7 days, set by jwt.client_access_token_expiry / jwt.client_refresh_token_expiry and never longer than the two settings above. Every refresh issues a new refresh token, so an app in use stays signed in until its client key expires (30 days by default, or the ttl_secs it was minted with); an app left unused for longer than the refresh lifetime signs in again.
  • Issuer: jwt.issuer (the shipped example config uses calimero-auth).

Access-token claims:

Claim Meaning
sub Subject: an opaque user ID, stable across logins and refreshes; a password reset issues a new one. Forwarded as X-Auth-User.
key_id The key the token authenticates with. A client key’s ID changes on every refresh.
iss Issuer (jwt.issuer).
aud Audience — the client ID.
exp / iat Expiry and issued-at, as Unix timestamps.
jti Unique token ID.
permissions The permission strings granted to the token.
node_url Optional node URL the token is scoped to.

The permissions claim is the part of the token that actually gates API access. It is a list of permission strings; the guard parses each one back into a structured Permission and checks it against what the requested route needs. A permission has three parts:

  • A category — one of admin, application, package, blob, context, or keys — usually followed by an action (and sometimes a sub-action), e.g. application:install, blob:add:stream, keys:create.
  • A resource scope in [...] — either omitted/* for all resources or a specific id, e.g. context:execute[<ctx-id>] limits the grant to one context.
  • A user scope for the permissions that carry one (context:execute, context:invite, context:leave) — * for any identity or a specific identity, encoded as the second field inside the brackets.

The brackets are positional: category:action[<resource>,<user>,<method>]. context:execute[<ctx>,<identity>,<method>] is the fullest form — execute on one context, as one identity, restricted to one method — and any trailing field may be left empty to widen the scope.

Example string Grants
admin Everything. The master permission satisfies every other check.
application:install Install applications (any application).
application:list[<app-id>] List, scoped to one application id.
blob:add:stream Add blobs via the streaming upload path only.
keys:create Mint client keys.
context All context operations (any context).
context:execute[<ctx>,<identity>,set] Call only the set method, on one context, as one identity.
context:capabilities:grant / context:capabilities:revoke Manage the per-context capability grants on the auth side.

An admin token carries a single string:

{ "permissions": ["admin"] }

A scoped frontend token grants only what one app’s UI needs — read its context, call a couple of methods, and add blobs:

{
"permissions": [
"context:list[<ctx-id>]",
"context:execute[<ctx-id>,<identity>,set]",
"context:execute[<ctx-id>,<identity>,get]",
"blob:add:stream"
]
}

A route is admitted on the permission its path needs, and some paths are several authorities in one. /ws is admitted on context:subscribe, yet the same socket takes execute frames; /jsonrpc is admitted on context:execute, yet the context and method are in the request body. So every execute call, on either transport, is checked again against the token’s context:execute grants for the context and method it actually names. A subscribe-only token cannot write over /ws, and a grant scoped to get cannot call set. This holds for client keys too, though they are otherwise treated as the node owner.

In proxy mode the node reads the token’s permissions from the X-Auth-Permissions header that mero-auth’s /auth/validate returns, which the proxy must forward onto the request (Traefik: list it in authResponseHeaders). A request that names none is answered as before, so a proxy that does not forward it loses this check rather than breaking.

The signing secrets are generated by the auth service itself and rotated on a schedule — there is no key file for an operator to provision or rotate by hand. Two independent secrets exist: the JWT auth secret (signs access and refresh tokens) and a CSRF secret.

  • Cadence. A background task wakes every hour and rotates any secret that has reached the rotation interval — 24 hours by default. Each rotation generates a fresh 32-byte random value as the new primary.
  • Storage layout. Each secret is written under a primary storage key and mirrored to a backup key. The backup is a durability fallback: if the primary cannot be read, the service restores it from the backup. Each stored secret also carries a 48-hour expiry that ages out the retained prior copy in memory.
  • Validation uses the current secret. Tokens are verified against the current primary signing secret only. A rotation therefore caps the practical lifetime of already-issued tokens at the rotation interval — clients re-authenticate through the normal login flow once their tokens stop validating.

One more time bound matters when integrating a login flow:

  • Client-key rotation on refresh. When a client token is refreshed, the auth service rotates the underlying key id — it derives a new client id, stores the new key, then deletes the old one. The previous client id no longer exists after a successful refresh, so always keep the newest token pair you receive. Root tokens are the exception: a refresh keeps the same key id.

A token may carry an optional node_url claim scoping it to a specific node. When that claim is present, the auth service validates it against the request’s Host header (or X-Forwarded-Host when a proxy sets it): if the host the token was minted for does not match the host the request is hitting, validation fails.

This matters behind a shared reverse proxy fronting several nodes — a token minted for node-a.example.com will not validate against node-b.example.com, even though both sit behind the same proxy. Requests coming from the internal auth service (host exactly auth, or auth:<port> with a numeric port) skip the check; because the port must be all digits, forged hosts such as auth:evil.example.com cannot use it to bypass binding.

The auth service loads providers from a registry and enables them per the [providers] map in its configuration. Today the registry ships a single auth provider:

Provider ID Enable with Status
Username / password user_password [providers] user_password = true Available, enabled in the shipped example config.

The user_password provider validates a { username, password } credential and has tunable bounds via [user_password] (min_password_length default 8, max_password_length default 128).

For CI and local development, the auth service can expose a mock-token endpoint at POST /auth/mock-token that mints valid JWTs without any authentication flow. It is part of the [development] config and is disabled by default.

Option Default Purpose
enable_mock_auth false Enable the mock endpoint. Returns 404 when off, and 403 to a request a browser sent (it carries Origin or Sec-Fetch-Site), so a browser page never gets a mock token. A malformed body is answered 400 before that check.
mock_auth_require_header true Require an Authorization header on the mock endpoint.
mock_auth_header_value unset Specific header value the endpoint requires (e.g. Bearer test-auth-token).

A minimal dev configuration:

[development]
enable_mock_auth = true
mock_auth_require_header = true
mock_auth_header_value = "Bearer test-auth-token"

Requesting a token:

Terminal window
curl -X POST http://localhost:3001/auth/mock-token \
-H "Content-Type: application/json" \
-H "Authorization: Bearer test-auth-token" \
-d '{ "client_name": "ci-test-client", "permissions": ["admin"] }'

The response carries access_token and refresh_token, plus warning headers (X-Mock-Token: true, X-Warning: Mock token - for testing only) and a key ID prefixed with mock_ so mock tokens are traceable. The tokens are real, fully-valid JWTs that pass all normal validation — which is exactly why the endpoint must never be enabled in production.

After a login the node’s login page hands the new token pair to the app’s callback-url. It does so only for an origin it trusts: the node’s own, a loopback app on a loopback node, origins built into the login page, and the frontend the app registry declares for the app. A preview deployment of an app (a Vercel preview, say) runs on an origin the registry does not declare, so its login is refused with “Login callback destination is not allowed.”

A node’s operator can trust more origins in [development]:

[development]
trusted_callback_origins = [
"https://mero-crm-git-my-branch-calimero-network.vercel.app",
]

In a node’s config.toml this is [server.embedded_auth.development]; the desktop app edits it under Settings → Advanced → Trusted App Origins (Developer Mode on). The list is published on GET /auth/providers as trusted_callback_origins, and the login page reads it from its own node only, never from a URL parameter, so a link cannot add to it.

Each entry must be an exact http or https origin (scheme://host[:port]): an entry with a path, query, credentials or a wildcard is dropped with a warning. Changes take effect when the node restarts.

In embedded mode the auth service mints a JWT for an authenticated client, and the node’s admin API validates that bearer token in-process on every protected call:

  1. Initialize (or reconfigure) the node for embedded auth:

    Terminal window
    merod --node node1 init --auth-mode embedded
  2. Confirm server.auth_mode = "embedded" in config.toml and that an embedded-auth section is present.

  3. Run the node. The auth store is created under <home>/node1/auth:

    Terminal window
    merod --node node1 run
  4. Authenticate against the auth endpoints to obtain a JWT, then send it as a bearer token to the admin API. The flow and endpoints are covered in the admin API reference.

The mero-auth crate carries more surface than node operators normally touch. The following exist in the crate but are beyond this operate page; consult the auth service directly if you need them:

  • Root-key and client-key management endpoints (creating, listing, and deleting keys, and per-key permission management).
  • CORS and security-header configuration ([cors], [security.headers], CSP) — relevant when serving the auth UI cross-origin.
  • Standalone operation: mero-auth can also run as its own service (default listen 127.0.0.1:3001) rather than embedded in the node; that deployment shape is not covered here.