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:
- 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: thedashclaw_pairtool generates + stores the keypair locally and submits in one call). The call returns apairing_url. Both SDKs can then poll withwaitForPairing(pairingId)/wait_for_pairing(pairing_id). - 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 afterDASHCLAW_PAIRING_TTL_MINUTES(default 15). - 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:
- reads its unread inbox and finds the
dashclaw.pairing_requestdirective, - generates an RSA-2048 keypair locally — the private key is written to
~/.dashclaw/identity/<agent_id>.pem(mode 600) and never leaves the machine, - POSTs the public key to
/api/pairingsand 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: falsein the plugin config. - Key rotation: delete the
.pemfile, 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
- The agent attaches
Authorization: Bearer <JWT>to DashClaw API calls. - DashClaw reads the
issclaim from the JWT and fetches JWKS from{iss}/.well-known/jwks.json(cached for 1 hour). - The signature is verified using the matching key (
kid→ JWK lookup). - Expiry (
exp), and optionally audience (aud), are validated. - If verification succeeds, the JWT
subclaim becomes the canonicalagent_idin the audit entry — cryptographic proof beats self-assertion. - The
verification_statusfield in every guard response and audit record reflects the outcome.
verification_status enum
| Value | Meaning |
|---|---|
verified | Signature valid; sub used as agent_id. Requires DASHCLAW_ALLOWED_ISSUER — unreachable without it (v3.7 fail-closed) |
unverified | No 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) |
expired | Signature valid, but exp is in the past |
failed | Bad signature, JWT-shaped but malformed, or aud mismatch |
unknown_issuer | iss does not match DASHCLAW_ALLOWED_ISSUER |
exp_too_far | exp 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
- After signature verification succeeds, DashClaw extracts the
jticlaim. - It calls an atomic
INSERT ... ON CONFLICT DO NOTHING RETURNING jtiagainst thejwt_replay_logtable, keyed by(issuer, jti). - A returned row means first use; an empty result means replay detected.
- The outcome is recorded in
guard_decisions.replay_statusalongsideverification_status. Replays forcedecision = '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
| Value | Meaning |
|---|---|
not_applicable | No JWT (Phase 1 / legacy path) |
disabled | Verified JWT but DASHCLAW_JTI_REPLAY_PROTECTION=off |
unique | First time this (issuer, jti) was seen → guard proceeds |
replayed | Same (issuer, jti) seen before → guard blocks |
not_present | Verified token did not include a jti claim (or jti > 1024 chars) |
unavailable | Replay store unreachable |
exp_too_far | Token exp exceeds the configured TTL cap |
Modes
Set DASHCLAW_JTI_REPLAY_PROTECTION to one of:
| Mode | replayed | not_present | unavailable |
|---|---|---|---|
off | allow | allow | allow (skipped) |
best_effort | block | allow | allow |
required | block | block | block |
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_effortmode, an issuer that doesn't emitjti(or strips it under attack) bypasses replay protection entirely.requiredmode closes that gap by denying any verified token that lacks ajti. Make sure your IdP always emitsjti(and a boundedexp) 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-sweepruns 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/guardis 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_type → action,
target → target, declared_goal → goal — and compares. (target is a new
optional guard-input field; the binding tuple needs a resource.)
act_status enum
| Value | Meaning |
|---|---|
not_applicable | No verified token (Phase 1 / unverified path) |
match | Claim present, digest matches the call |
mismatch | Claim present, digest does not match → guard can block |
not_present | Verified token carried no binding claim |
unsupported_typ | Binding typ not in DASHCLAW_ACT_BINDING_TYP |
ctx_incomplete | Request 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
| Mode | mismatch | not_present | unsupported_typ | ctx_incomplete |
|---|---|---|---|---|
off | record | record | record | record |
best_effort | block | record | record | record |
required | block | block | block | block |
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..."
}
| Claim | Required | Used by DashClaw |
|---|---|---|
iss | Yes | JWKS discovery ({iss}/.well-known/jwks.json) |
sub | Yes | Canonical agent_id when verified |
agent_name | No | Human-readable label in audit entries |
aud | No | Validated when DASHCLAW_JWT_AUDIENCE set |
exp | No | Checked before JWKS fetch (fast path) |
jti | No | Replay-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.