Agent Identity

September 5, 2026 · View on GitHub

DashClaw supports cryptographically verifiable agent identity via standard JWT bearer tokens. Any OIDC-compatible issuer works — Keycloak, Auth0, a custom JWKS server, or AgentLair.

Phase 1 — who is asking (harness attribution)

Before any cryptography, every action carries a self-asserted agent_id. Since v2.2 that identity is per-harness: each installer writes an explicit --agent-id <id> flag onto its hook command line (Claude Code: claude-code or the id chosen at install; Codex: codex; Hermes shims: DASHCLAW_HERMES_AGENT_ID or hermes), and the hooks resolve identity as argv flag > DASHCLAW_AGENT_ID env > harness default — so two harnesses on one machine report two identities even when a machine-wide DASHCLAW_AGENT_ID is exported. Claude Code sub-agents additionally get composed identities (<parent>:<type>, default DASHCLAW_SUBAGENT_IDENTITY=distinct); the server resolves their pairing, targeted policies, and agent-scoped x402 budgets through the base parent (docs/rfcs/2026-06-01-subagent-fleet-identities.md).

Getting an identity (enroll first)

Identity is additive — DashClaw works without it (Phase 1 attributes actions by the agent_id body field, derived as above). Add verifiable identity when you want stronger identity evidence for the principal that submitted an action. Identity evidence does not independently prove that an external effect occurred. There are two enrollment paths:

A. Public-key pairing (no identity provider required). The simplest path for self-hosters who don't run an OIDC issuer:

  1. The agent generates a keypair and submits its public key: POST /api/pairings (Node SDK: claw.createPairing(publicKeyPem, { algorithm, agentName }); Python SDK: claw.create_pairing(public_key_pem); MCP agents: the dashclaw_pair tool generates + stores the keypair locally and submits in one call). The call returns a pairing_url. Both SDKs can then poll with waitForPairing(pairingId) / wait_for_pairing(pairing_id).
  2. Open the pairing_url (or Settings → Agent Identity / /identities) and approve the request as an admin — this also sets the agent's permission level. Approval is what creates the identity row (POST /api/pairings/{id}/approve); a plain PATCH cannot approve. Pairing requests expire after DASHCLAW_PAIRING_TTL_MINUTES (default 15).
  3. The agent signs its requests with the private key; approved keys appear under /identities.

Operator-initiated pairing requests. Admins can summon unidentified fleet agents to pair from /identities → Unidentified Agents → Request pairing. The request is delivered over the agent's message inbox (pull-based — the agent sees it the next time it runs with DashClaw attached and checks GET /api/messages?agent_id=<id>&direction=inbox&unread=true). The message body carries a fenced JSON directive the agent can recognize:

{
  "kind": "dashclaw.pairing_request",
  "agent_id": "<your agent id>",
  "dashboard_url": "<instance origin>",
  "action": "Generate a keypair, POST your PEM public key to /api/pairings, then await admin approval."
}

On seeing it, run enrollment path A above (MCP: dashclaw_pair), then mark the message read (PATCH /api/messages with { message_ids, action: "read", agent_id }).

OpenClaw agents answer automatically. The @dashclaw/openclaw-plugin consumes the pairing directive without an LLM in the loop. On the agent's first tool call after you click Request pairing, the plugin:

  1. reads its unread inbox and finds the dashclaw.pairing_request directive,
  2. generates an RSA-2048 keypair locally — the private key is written to ~/.dashclaw/identity/<agent_id>.pem (mode 600) and never leaves the machine,
  3. POSTs the public key to /api/pairings and marks the message read.

The pairing then appears under Pending Pairings on /identities for one-click approval. Approval remains the only step that creates the identity.

  • Disable with autoPairing: false in the plugin config.
  • Key rotation: delete the .pem file, then click Request pairing again.
  • The trigger is the agent's next tool call — an idle gateway does nothing until it runs a tool (delivery is pull-based by design).

B. JWKS-verified JWT (bring your own issuer). If you already run an OIDC-compatible issuer (Keycloak, Auth0, AgentLair, or a custom JWKS server), mint a JWT there and point DashClaw at the issuer via DASHCLAW_ALLOWED_ISSUER. The agent attaches Authorization: Bearer <JWT>. Obtaining the token is the issuer's job — DashClaw only verifies it (covered in the rest of this document).

How it works

  1. The agent attaches Authorization: Bearer <JWT> to DashClaw API calls.
  2. DashClaw reads the iss claim from the JWT and fetches JWKS from {iss}/.well-known/jwks.json (cached for 1 hour).
  3. The signature is verified using the matching key (kid → JWK lookup).
  4. Expiry (exp), and optionally audience (aud), are validated.
  5. If verification succeeds, the JWT sub claim becomes the canonical agent_id in the audit entry — cryptographic proof beats self-assertion.
  6. The verification_status field in every guard response and audit record reflects the outcome.

verification_status enum

ValueMeaning
verifiedSignature valid; sub used as agent_id. Requires DASHCLAW_ALLOWED_ISSUER — unreachable without it (v3.7 fail-closed)
unverifiedNo JWT, a Bearer that is not JWT-shaped (see below), no configured issuer (fail-closed since v4.49.0), or issuer temporarily unavailable (fail-soft)
expiredSignature valid, but exp is in the past
failedBad signature, JWT-shaped but malformed, or aud mismatch
unknown_issueriss does not match DASHCLAW_ALLOWED_ISSUER
exp_too_farexp exceeds DASHCLAW_JTI_MAX_TTL_SECONDS (default 24h)

Only a JWT-shaped Bearer can earn JWKS verification_status: verified (three base64url segments). The built-in OAuth server issues opaque oat_ tokens for /api/mcp. Middleware authenticates those against persisted token state and derives a stable OAuth client/user principal plus canonical read or write scope. That is authenticated OAuth attribution, not JWKS-verified agent identity, and it cannot satisfy human session/operator checks.

Phase 2b: replay protection

Phase 2 verifies who signed a token. Phase 2b prevents reusing one. A captured verified token can otherwise be replayed against the same audience inside its exp window — the signature stays valid until expiry. Adding jti plus a "seen set" closes that gap.

Design and shape by @piiiico in issue #120.

How it works

  1. After signature verification succeeds, DashClaw extracts the jti claim.
  2. It calls an atomic INSERT ... ON CONFLICT DO NOTHING RETURNING jti against the jwt_replay_log table, keyed by (issuer, jti).
  3. A returned row means first use; an empty result means replay detected.
  4. The outcome is recorded in guard_decisions.replay_status alongside verification_status. Replays force decision = 'block'.

Idempotent retries

An idempotency_key deduplicates the action, not the token's authorization. With replay protection enabled, retry the same action and key with a fresh JWT carrying a new jti. Reusing the original token must still block, even when its first call earned a cached allow.

Current replay-protection and action-binding restrictions are checked before serving a cached decision, including when an enforcement mode was tightened since the first call. A rejected retry goes through normal evaluation and the mandatory audit write; audit failure returns HTTP 503, never the cached allow. A fresh valid token does not inherit a prior token-replay rejection. Non-JWT callers retain normal idempotency behavior.

replay_status enum

ValueMeaning
not_applicableNo JWT (Phase 1 / legacy path)
disabledVerified JWT but DASHCLAW_JTI_REPLAY_PROTECTION=off
uniqueFirst time this (issuer, jti) was seen → guard proceeds
replayedSame (issuer, jti) seen before → guard blocks
not_presentVerified token did not include a jti claim (or jti > 1024 chars)
unavailableReplay store unreachable
exp_too_farToken exp exceeds the configured TTL cap

Modes

Set DASHCLAW_JTI_REPLAY_PROTECTION to one of:

Modereplayednot_presentunavailable
offallowallowallow (skipped)
best_effortblockallowallow
requiredblockblockblock

required is the default (v3.6, 2026-07-04 — graduated from best_effort when the verified-JWT fleet was measurably empty, so no existing traffic was affected): any uncertainty on verified traffic fails closed. The mode only applies to JWKS-verified tokens — API-key callers resolve replay_status='not_applicable' and are never blocked by this knob. Rollback is one env var: DASHCLAW_JTI_REPLAY_PROTECTION=best_effort (the pre-v3.6 fail-soft posture).

Security note — In best_effort mode, an issuer that doesn't emit jti (or strips it under attack) bypasses replay protection entirely. required mode closes that gap by denying any verified token that lacks a jti. Make sure your IdP always emits jti (and a bounded exp) so legitimate verified traffic isn't impacted.

Storage and sweep

Rows in jwt_replay_log carry an expires_at mirroring the token's exp, so each row becomes purgeable at the same instant the token does. Two sweeps run in tandem:

  • Probabilistic in-line: ~1% of writes trigger a DELETE WHERE expires_at < now.
  • Scheduled: GET /api/cron/jti-sweep runs every 5 minutes via .github/workflows/jti-sweep.yml.

The table never accumulates rows beyond one TTL window of inactivity.

Configuration

DASHCLAW_JTI_REPLAY_PROTECTION=required      # off | best_effort | required
DASHCLAW_JTI_MAX_TTL_SECONDS=86400           # cap on accepted exp (24h default)

Phase 2c: action binding

Phase 2 verifies who signed a token. Phase 2b stops reusing one. Phase 2c narrows what a single token can do: an issuer commits the token to one intended (action, target, goal) tuple at mint time, and the guard records whether the incoming call matches — so a token minted to read a record can't be silently repurposed to delete a different one (e.g. by a prompt-injected agent holding an over-broad token).

Position note. /api/guard is an advisory decision point, not an inline enforcement point — it does not sit in the data path to the resource. So act-binding's primary value here is an audit tripwire ("this verified token's declared intent doesn't match the call it showed up on") plus an opt-in block for agents that voluntarily consult the guard. It is not a substitute for the resource server validating the binding itself.

The binding claim

A namespaced claim — deliberately not act, which RFC 8693 reserves for a nested actor object (act.sub) that a hash-shaped payload would corrupt for any 8693-aware verifier:

"urn:dashclaw:act-binding": {
  "typ":  "action-binding/v1",
  "hash": "sha256:<base64url-digest>"
}

hash is a SHA-256 over the canonical tuple the issuer committed the token to. Canonicalization is a constrained RFC 8785 (JCS) profile: the three values are forced to strings, NFC-normalized, and serialized with lexicographically ordered keys and no whitespace. Both the issuer and DashClaw go through app/lib/act-binding.js so the bytes can't drift.

The guard hashes the incoming request context — action_typeaction, targettarget, declared_goalgoal — and compares. (target is a new optional guard-input field; the binding tuple needs a resource.)

act_status enum

ValueMeaning
not_applicableNo verified token (Phase 1 / unverified path)
matchClaim present, digest matches the call
mismatchClaim present, digest does not match → guard can block
not_presentVerified token carried no binding claim
unsupported_typBinding typ not in DASHCLAW_ACT_BINDING_TYP
ctx_incompleteRequest lacked action/target/goal to compute the digest

act_status is recorded on guard_decisions in every mode, including off — that's the point: it's the observable that tells an operator their issuer has started minting bindings and it's safe to enable enforcement. act_hash logs the claim-side digest only (the unfakeable half).

Modes

Modemismatchnot_presentunsupported_typctx_incomplete
offrecordrecordrecordrecord
best_effortblockrecordrecordrecord
requiredblockblockblockblock

Default is best_effort (v3.6, 2026-07-04 — graduated from off): it only ever blocks a positive mismatch, which requires a present binding claim, so issuers that don't mint the claim see zero behavior change while an actually repurposed token starts blocking. required stays opt-in (not the default, unlike replay protection): it blocks not_present, which would make minting the claim a precondition for adopting JWKS at all. Flip to required once act_status='match' shows up in your guard_decisions — that signal is recorded in every mode for exactly this purpose. Rollback is one env var: DASHCLAW_ACT_BINDING=off.

Configuration

DASHCLAW_ACT_BINDING=best_effort               # off | best_effort | required
DASHCLAW_ACT_BINDING_TYP=action-binding/v1     # accepted typ list (comma-separated)

Resilience

DashClaw uses a fail-soft model: if the JWKS endpoint is unreachable or slow, tokens resolve to unverified rather than failed. A downed identity provider cannot block agent decisions. Phase 1 body-field attribution (agent_id / agent_name in the request body) is always the fallback.

The JWKS fetcher includes:

  • 1-hour cache per issuer — eliminates per-request latency
  • Circuit breaker — opens after 3 consecutive fetch failures; stays open for 30 s, then half-opens for retry
  • 5-second fetch timeout — prevents slow JWKS from adding audit latency

Configuration

Set these environment variables. No YAML config file is needed.

# REQUIRED to enable verification (v3.7 fail-closed): with no issuer configured,
# bearer tokens never reach 'verified' — there is no trust anchor. Tokens from
# other issuers → verification_status = 'unknown_issuer'.
DASHCLAW_ALLOWED_ISSUER=https://idp.example.com

# Optional: require this value in the JWT 'aud' claim.
# Mismatch → verification_status = 'failed'.
DASHCLAW_JWT_AUDIENCE=dashclaw.production.example.com

Both env vars are optional. Without DASHCLAW_ALLOWED_ISSUER, no JWT can become verified because DashClaw has no trust anchor. Audience validation remains optional until DASHCLAW_JWT_AUDIENCE is configured.

Receipts and identity are separate evidence

A valid DashClaw receipt proves that the configured DashClaw issuer signed the canonical payload and that it has not changed. It does not universally prove who performed an external action. Agent identity comes from the authenticated principal and its verification_status; public-key pairing additionally signs the submitted action payload. Consumers must inspect those fields rather than treating receipt validity alone as identity verification.

JWT token schema

{
  "iss": "https://idp.example.com",
  "sub": "agt_7f3a2b",
  "agent_name": "review-worker-3",
  "aud": "dashclaw.example.com",
  "exp": 1744300800,
  "iat": 1744300500,
  "jti": "txn_a8f3..."
}
ClaimRequiredUsed by DashClaw
issYesJWKS discovery ({iss}/.well-known/jwks.json)
subYesCanonical agent_id when verified
agent_nameNoHuman-readable label in audit entries
audNoValidated when DASHCLAW_JWT_AUDIENCE set
expNoChecked before JWKS fetch (fast path)
jtiNoReplay-protection key (Phase 2b, shipped)

Supported algorithms

EdDSA (Ed25519), RS256/384/512, ES256/384/512.

SDK usage

import DashClaw from 'dashclaw';

const dashclaw = new DashClaw({
  baseUrl: 'https://dashclaw.example.com',
  apiKey: 'dc_key_...',
  // Phase 1 trust-on-assertion (still works):
  agentId: 'agt_7f3a2b',
  agentName: 'deploy-checker',
  // Phase 2 JWKS verification — pass your AAT as a bearer token:
  authToken: '<your-jwt-from-your-idp>',
});

const result = await dashclaw.guard({ action_type: 'deploy' });
console.log(result.verification_status); // 'verified' | 'unverified' | ...

Example: AgentLair

AgentLair (agentlair.dev) issues Ed25519-signed JWTs (Agent Audit Tokens) with a persistent sub (stable agent_id) and publishes JWKS at https://agentlair.dev/.well-known/jwks.json.

To use AgentLair as your identity provider:

DASHCLAW_ALLOWED_ISSUER=https://agentlair.dev
DASHCLAW_JWT_AUDIENCE=dashclaw.example.com  # optional

No other changes are needed — the standard bearer token flow works as-is.

Example: Keycloak

DASHCLAW_ALLOWED_ISSUER=https://keycloak.example.com/realms/agents
# JWT iss must match exactly. JWKS auto-discovered from:
# https://keycloak.example.com/realms/agents/.well-known/jwks.json

Example: Auth0

DASHCLAW_ALLOWED_ISSUER=https://your-tenant.auth0.com/
# JWKS auto-discovered from:
# https://your-tenant.auth0.com/.well-known/jwks.json

Backward compatibility

Phase 2 is fully additive. Existing integrations using Phase 1 body-field attribution continue to work without any changes. The only difference is that verification_status will be unverified instead of absent.