Identity and enrollment
Every participant in a mesh, node or router, has a key that it generated itself and a credential that the control plane issued for that key. Callers that reach the mesh through a node (workloads, users, and sandboxed agents) exchange their platform JWTs into delegated credentials bound to that node’s channel, and narrow them offline per task. This page follows credentials from enrollment and exchange to attenuation, outbound federation, renewal, and revocation.
Keys and peer IDs
The first thing a node does is generate an Ed25519 key pair and store it in
its data directory (~/.config/agentmesh/agent.db by default). Its peer
ID, the 12D3KooW... string that appears in logs, discovery results and
proxy URLs, is derived from the public key. The key never leaves the machine.
The credential is bound to the key, so a copied credential is useless without
it, and every peer-to-peer connection proves possession of the key as part
of the libp2p handshake.
agentmesh-node reset --all deletes the key and gives the node a new peer ID.
agentmesh-node reset without --all keeps the key and deletes only the
credential.
Enrollment
Enrollment is a request to the control plane: “here is my public key, here is who I am, please issue me a credential for role X with labels Y”. There are three ways to say who you are.
Interactive OIDC login. agentmesh-node join <control-plane-url> fetches the
control plane’s /info, learns which identity provider it trusts, and runs an
OpenID Connect login. If a browser is available, it uses the loopback flow.
On a headless machine it uses the device flow (a URL and a code that you enter
on another device), or falls back to pasting a code. --auth-mode selects
one of these explicitly. The ID token from the login is sent to
POST /register together with the public key and a signature over a fresh
challenge. This is how a person enrolls a laptop.
Non-interactive OIDC. A workload that already has an OIDC token does not
need a login. agentmesh-node run --jwt-path <file> enrolls with the token in that
file (such as a Kubernetes projected service account token or a SPIRE
JWT-SVID written by spiffe-helper). On GCE and Cloud Run,
agentmesh-node run --cloud-provider gcp (or auto) fetches an identity token
directly from the instance metadata server. --client-id and
--client-secret-path do the same with an OAuth client-credentials grant.
Routers enroll in the same way with agentmesh-router --jwt-path. The control
plane marks workload issuers with --workload-issuer (<issuer> or
<issuer>=<email-suffix>) so workload tokens can enroll, refresh, and
exchange credentials while being refused at human operator endpoints
(/user/*, /oauth/authorize). A workload issuer that does not answer at
start-up does not stop the control plane. The control plane retries the
issuer’s discovery in the background, with a delay that grows from 5 seconds
to 5 minutes. Until the issuer answers, the control plane refuses tokens from
it. agentmesh_control_plane_workload_issuer_ready{issuer} shows which
issuers have resolved.
Bootstrap token. An operator mints a token with the admin API, the
console, or agentmesh-one token create, and copies it to the machine. The node
sends it to POST /enroll. Unless the control plane runs with
--auto-approve-enrollment, the request waits in a queue until an
administrator approves it, and the node polls GET /enroll/status while it
waits. A token has a role, an expiry and a usage count. A single-use token is
spent as soon as one node is approved with it. This is how machines without
an identity of their own enroll, and how agentmesh-one enrolls devices by QR
code.
For all three paths, the control plane checks these things before it mints a credential:
- The peer ID matches the submitted public key, and the challenge signature verifies under that key. This proves that the requester holds the key it is registering.
- Neither the peer ID nor the identity behind it is banned.
- The requester may hold the role it requests.
agentmesh-noderequestsmesh:role:nodeandagentmesh-routerrequestsmesh:role:router. On the two OIDC paths, the identity must resolve to that role through the bindings in the mesh policy. If the policy binds nobody tomesh:role:node, no node can enroll with an OIDC identity. On the bootstrap token path, the requested role must be the token’s role. The control plane does not consult the bindings for the role. - Every label the node declared is permitted by the
allowed_labelsof a role it holds. A role withoutallowed_labelspermits no labels.
The node stores the credential, the control plane’s public key, the control
plane URL and the router addresses it received. From then on it is a member
of that mesh only, because a credential is only valid for the mesh that
issued it. If you point agentmesh-node run --join at a different control
plane on a terminal, the node asks for a confirmation. Then the node replaces
the stored credential and keeps its peer ID. Without a terminal, the node
refuses until you run agentmesh-node reset.
The credential
Inside the mesh, every credential is a Biscuit,
a signed authorization token. Its authority block (Block 0) holds facts
written in Datalog, a small logic language in which a fact looks like
role("mesh:role:node"). The authority block is signed by the control plane’s
Ed25519 key. Any node with the public key can verify it without contacting
anyone.
The control plane mints two kinds of Biscuits:
1. Member Biscuit (POST /register, POST /enroll, POST /refresh)
Issued to an enrolled node, router, or native SDK peer. Its authority block contains:
| Fact | Meaning |
|---|---|
node("12D3KooW..."), client_peer_id("12D3KooW...") | The peer ID the token belongs to. Every verifier checks that the connection it arrived on was authenticated as this peer. |
expiration(<time>) | When the token stops being valid. |
role("mesh:role:node"), role("developer") | The roles the identity resolved to, one fact each. |
user("..."), email("..."), group("..."), idp_role("...") | Claims copied from the OIDC token: subject, verified email, each group, each entry of the issuer’s roles claim. Absent for bootstrap enrollments. |
label("region", "eu") | One fact per declared and permitted label. |
granted_service_*, granted_target_*, granted_publish_* | What the roles allow, compiled from allowed_services, allowed_targets, and allowed_publish. Authorization describes them. |
http_granted_service_*, granted_method, granted_method_any, granted_path_* | The service, method, and path limits of each http entry. The control plane mints these facts in place of granted_service_* for an allowed_services entry that an http entry narrows. |
Facts from custom_datalog | Each fact written in a role’s custom_datalog. A rule written there is not minted. The control plane ships it to the nodes with the policy. |
right("relay"), target_unrestricted(true) | The control plane mints them for mesh:role:router in place of grants. A router may relay, and no target check applies to it. |
The node does not set any of these facts. Roles come from the bindings in
the mesh policy, not from the identity provider: an issuer’s roles claim
is stored as idp_role(), which grants nothing by itself. Labels are the
ones the node asked for and the policy allowed. Grants come from the policy.
2. Delegated Session Biscuit (POST /token/exchange, /oauth/token)
When a workload or user calls through an enrolled agentmesh-node (or an Envoy /
Istio / agentgateway proxy integrated with agentmesh-node), the node exchanges
the caller’s platform JWT (OIDC ID token, Kubernetes projected SA JWT, or
SPIFFE JWT-SVID) at POST /token/exchange on the control plane. The control
plane verifies the node’s credential and proof-of-possession signature,
validates the caller’s subject_token, resolves the caller’s roles against
the mesh policy, and mints a short-lived Delegated Session Biscuit with zero
database writes:
client_peer_id("12D3KooW...")andactor_node("12D3KooW...")bind the token to the origin node’s transport channel so only that node can present it over libp2p, and record the acting node for audit logs and outbound STS.- No
node()fact is minted, sonode:<peer_id>bindings on the origin node never leak to the delegated caller. user("..."),email("..."),group("..."),idp_role("..."), androle("...")reflect the caller’s verified identity and resolved roles.
Task attenuation (tar_block) and sealing
Before starting a task or delegating to a sub-agent, any holder (agentmesh-node,
an orchestrator via POST /oauth/token, or an SDK caller via
session.attenuate(rule)) can narrow a Biscuit offline by appending up to 8
blocks.
Each appended block (block_idx >= 1) carries zero Datalog rules, zero Datalog
checks, and exactly one fact:
tar_block("<base64url-serialized api.TaskAuthorizationRule>")
Every verifier decodes the TaskAuthorizationRule chain and requires the
request to satisfy both Block 0’s standing Datalog policy and every appended
TaskAuthorizationRule block (strict intersection across hops). For untrusted
leaf sandboxes, the holder calls Seal() (session.seal() or seal=true on
POST /oauth/token), which discards the ephemeral next-block key so no
further blocks can be appended.
Outbound federation: the control plane as OIDC issuer
External cloud providers do not accept Biscuits, and Agent Mesh never forwards a
caller’s mesh token to an upstream API. Instead, the control plane acts as a
standard OIDC issuer (/.well-known/openid-configuration and /jwks, signed
with ES256).
When an egress node serves a destination configured with oidc_federation or
aws_assume_role, it verifies the caller’s Biscuit and tar_block chain and
calls POST /sts/token on the control plane. The control plane re-verifies
the token and mints a short-lived ES256 border JWT (sub = caller principal,
act.sub = egress node peer ID, aud = destination audience, mesh_roles =
caller mesh roles, mesh_task = innermost task name), which the egress node
exchanges at the cloud provider’s STS endpoint (such as Google Workload or
Workforce Identity Federation or AWS AssumeRoleWithWebIdentity).
Lifetime and refresh
A Member Biscuit is valid for --biscuit-ttl, 24 hours by default (or the
OIDC token’s expiry if sooner). Delegated Session Biscuits default to 1 hour
(bounded by the subject JWT’s expiry and any TaskAuthorizationRule.expire_time).
A node sets one timer from its credential’s expiry. While more than six hours
remain, the timer fires 4.8 hours before the expiry. With less time left, the
timer fires halfway to the expiry. A router checks on every control plane
sync pass, each --control-plane-sync-interval (5 minutes by default), and
refreshes once less than 4.8 hours remain. These thresholds stay the same
when --biscuit-ttl changes. Both call POST /refresh with the
current token and a signature over a fresh challenge. When the member has a
live platform token source (--jwt-path, --cloud-provider, --client-id,
or an SDK jwtPath / jwt callback), it also sends a fresh platform JWT in
TokenRefreshRequest.jwt: the control plane verifies that iss|sub matches
the enrolled node record, refreshes the stored claims and session in place,
and re-resolves the identity’s roles against the current policy.
Refresh without a fresh platform JWT is bounded by the session, which is
a record on the control plane, not a field in the token. A human OIDC session
lasts --oidc-session-ttl (90 days by default), and a workload OIDC session
(--workload-issuer) lasts --workload-session-ttl (48 hours by default). A
bootstrap enrollment has no session expiry: the token was the proof the member
gave once, and refreshing on its key is what keeps it enrolled. Its record is
kept for --node-retention (30 days) after its last refresh and then deleted,
as a self-hosted CI runner is removed after a fortnight without connecting; a
member that comes back later enrolls again. A node started with
--bootstrap-token-path enrolls again on its own once the control plane
answers 401 to an authenticated call. The node accepts the new credential
only if a control plane key that the node already trusts signed it. A node
that was offline past the retirement of the key that signed its credential
therefore needs an operator, as
Signing keys and rotation describes. A token
minted with ephemeral
enrolls members kept for --ephemeral-node-retention (an hour) instead, for
fleets, probes and CI jobs that come and go. A ban also acts on the session
record.
A node that enrolled interactively with --offline-access also keeps an OIDC
refresh token. If its credential expires completely, it uses the refresh
token to obtain a new ID token and re-enrolls under the same peer ID without
any user action.
Signing keys and rotation
The control plane rotates its Biscuit Ed25519 signing key every
--key-rotation-interval (24 hours by default). The previous key stays valid
for --key-grace-period (1 hour by default) and is then retired. With
--signing-key-path, the operator supplies the keys in a PEM file instead,
and automatic rotation is off. The first Ed25519 key in the file signs
Biscuits. The control plane re-reads the file every 30 seconds. To rotate,
the operator puts a new key first and keeps the previous key in the file
until every credential that key signed has expired. GET /keys
returns the current set of keys, signed by each key in the set. Routers and
nodes fetch it at enrollment and then every --control-plane-sync-interval,
together with the ban set and the mesh policy. Nodes also pull the revocation
list and their egress assignments. A rotation event brings the next pull
forward. A node also trusts the event’s new_public_key at once. Routers and
nodes accept a new set only if one of its signatures verifies under a key they
already trust. The first key comes from enrollment, and each later key is
vouched for by the key it replaces. A node or router whose credential predates
a key in the new set refreshes that credential on the same sync pass.
Nobody can verify a credential signed by a retired key, including the control plane. Under automatic rotation, the control plane retires the key that signed a credential one grace period after the next rotation. With the defaults, the retirement comes at most 25 hours after the credential was issued. A node that was offline past that point therefore cannot refresh. This is intentional. The retirement is the deadline after which a machine that went quiet cannot come back unnoticed. How it comes back depends on how it enrolled:
- An OIDC node with a refresh token re-enrolls on its own.
- A bootstrap node needs an operator to mint a new token and run
agentmesh-node joinagain (or to restart the router with the new token). The node keeps its peer ID. The control plane recognises the approved peer and mints a new credential without going through the approval queue. - A bootstrap node whose record has
autonomous_recoveryset may still refresh. The node presents the exact last credential the control plane issued to it, with a signature by its key. This is off by default, and an administrator sets it per token or per node. A node that can always recover holds a credential that never expires, and only a ban stops it.
Revocation
Revocation operates at two levels:
- Mesh-wide node, identity, and root-prefix Biscuit revocation:
POST /admin/revokewith a peer ID (oragentmesh-one admin ban, or the console) marks the node as banned. Routers and peers drop the node as soon as the ban event reaches them. The control plane refuses the node’s next refresh, but the node keeps its current credential. The daemon exits when its connection monitor fails three router checks in a row, one minute apart by default. The control plane publishes banned peer IDs in/infoandGET /revocations, together with revoked root Biscuit revocation IDs (RevocationIds()[0]). Because every offline-attenuated child Biscuit shares the root authority block’sRevocationIds()[0], revoking the root credential invalidates every attenuated task token derived from it across the mesh. If the node was enrolled through OIDC, the identity behind it (issuer|subject) is banned too.POST /admin/nodes/{peer_id}/unban, or Restore in the console’s Revoked Nodes list, reverses both bans. - Local task token revocation (
POST /oauth/revoke): When an orchestrator or sandbox finishes a task before its TTL expires, it callsPOST /oauth/revoke(RFC 7009) on the localagentmesh-node, which records the leaf token’sRevocationIds()[last]in its local revocation cache until the token’sexpire_time.
A bootstrap token can be revoked before it expires with
DELETE /admin/bootstrap-tokens/{id}. The token stays in the list, marked
as revoked, so the record of what it enrolled is kept. The control plane
deletes the token once the token has been expired for --node-retention
and no enrollment request made with it is left.
What a node proves on every connection
The control plane’s HTTP API is protected by challenge signatures, because a
peer ID in a request body is otherwise only a claim. On the mesh, the libp2p
secure channel already proves which key is on the other end, so the handshake
between two nodes is simpler. Each side sends its credential. Each side
verifies the other’s signature and expiry, checks that the credential’s
node() fact matches the authenticated peer, and checks the ban and
revocation lists. On per-request streams, the destination node verifies either
the caller’s Member Biscuit or a Delegated Session Biscuit whose
client_peer_id() matches the authenticated connection peer, and then
evaluates standing Datalog policy and any appended tar_block chain.
See also
- Headless enrollment for the bootstrap token workflow step by step.
- Agent architecture for the two-token STS model and task attenuation.
- Control plane reference for the flags and HTTP routes named here.