Identity and Ownership Claims
A record can carry two signed claims: one for the identity of the record itself and one for the owner behind it. A claim ties the record to a subject (a domain, a DID, a SPIFFE ID or an ANS name) and is proven with a signature that anyone can check against key material the subject publishes, or against a certificate the claim carries. The reconciler verifies every claim on a schedule and stores the outcome, so a rotated key, a revoked trust bundle or a revoked agent shows up on the next run.
This page describes the claim schema, how each kind of subject is verified, and how to read the results. For the commands, see CLI Reference — Identity; for a walkthrough, see Usage Guide — Identity and Ownership Claims.
How it works
sequenceDiagram
participant P as Publisher
participant S as Directory server
participant R as Reconciler (identity task)
participant K as Subject (DNS, HTTPS, DID, SPIFFE bundle, ANS log)
participant D as Clients (CLI, SDK, search)
P->>S: Push record declaring agntcy.dir/owner
P->>S: Push signed claim as a record referrer
loop every reconciler.identity.interval
R->>S: Read the record's claims
R->>K: Look up the subject's current public keys
R->>R: Check the claim and its signature
R->>S: Store verified or failed
end
D->>S: GetIdentityStatus, or search --owner-verified
- The record declares the subject it claims in its
agntcy.dir/identityandagntcy.dir/ownerannotations. - The publisher signs a claim for that subject with a private key whose public half the subject publishes, and attaches it to the record as an OCI referrer.
- The reconciler's
identitytask verifies the claim: it resolves the subject's current keys and checks the signature against them. The result is stored in the server database. - The server reports the result through the
IdentityServiceAPI,dirctl identity statusand the search filters.
A claim is never verified at the time it is pushed. Until the task has run, the claim has
no result.
Declaring the subject
A claim is only evaluated against the subject its record declares. The declaration is part of the record, so it is covered by the record's CID and cannot be changed afterwards.
| Annotation | Role | Meaning |
|---|---|---|
agntcy.dir/identity |
identity |
The record's own claimed identity |
agntcy.dir/owner |
owner |
The entity that owns the record |
{
"name": "example.com/agents/research-assistant",
"version": "v1.0.0",
"annotations": {
"agntcy.dir/identity": "did:web:example.com:agents:research-assistant",
"agntcy.dir/owner": "dns:example.com"
}
}
A record may declare one, both or neither. A claim for a role the record does not declare is
refused when it is created with dirctl identity claim or the SDK, and is ignored by the
reconciler if it is pushed by hand.
Names
A record's name field is how it is referenced as name, name:version or
name:version@cid. A name is a label chosen by the publisher and is not verified. The
claims on this page are what prove who stands behind a record.
Claim schema
A claim is the agntcy.dir.identity.v1.Claim message. It is stored as a
record referrer of the record it is about, with the message encoded
as JSON in the referrer's data.
| Field | Type | Description |
|---|---|---|
role |
ClaimRole |
CLAIM_ROLE_IDENTITY or CLAIM_ROLE_OWNER |
recordCid |
string | CID of the record the claim is attached to |
subject |
string | The identity URI being claimed; must equal the record's annotation for the role |
signedAt |
string | RFC 3339 signing time |
expiresAt |
string, optional | RFC 3339 expiry; an expired claim fails |
signature |
string | Detached JWS (RFC 7515) in compact serialization |
certificate |
string, optional | Base64 DER X.509 certificate; only for spiffe:// and ans:// subjects |
The referrer type follows the role:
| Role | Referrer type |
|---|---|
identity |
agntcy.dir.identity.v1.IdentityClaim |
owner |
agntcy.dir.identity.v1.OwnershipClaim |
{
"role": "CLAIM_ROLE_OWNER",
"recordCid": "baeareie...",
"subject": "dns:example.com",
"signedAt": "2026-10-02T10:15:00Z",
"expiresAt": "2027-10-02T10:15:00Z",
"signature": "eyJhbGciOiJFUzI1NiJ9..<signature>"
}
What is signed
The signature covers a canonical JSON payload of five fields, in this order:
{"record_cid":"...","role":"CLAIM_ROLE_OWNER","subject":"dns:example.com","signed_at":"...","expires_at":"..."}
expires_at is the empty string when the claim does not expire. Because the CID and the role
are signed, a claim cannot be replayed against another record or presented as the other role.
The signature and certificate fields are not part of the payload.
Warning
The certificate is not covered by the signature. For that reason it is only accepted
on a spiffe:// or ans:// claim, where it is validated on its own (against a trust
bundle, or against the agent's transparency log), and a claim for any other subject that
carries one fails.
Signing keys
The signature algorithm is chosen from the public key, never from the JWS header, so a claim cannot pick its own verification algorithm.
| Key | JWS algorithm |
|---|---|
| ECDSA P-256 | ES256 |
| ECDSA P-384 | ES384 |
| Ed25519 | EdDSA |
| RSA, 2048 bits or more | RS256 |
Subjects and key publication
The scheme of the subject decides how its keys are found. A claim verifies when any one of the keys the subject currently publishes validates its signature.
| Subject | Where the key comes from |
|---|---|
dns:example.com, or a bare example.com |
TXT record at _agntcy-key.example.com |
https://example.com |
https://example.com/.well-known/jwks.json |
did:web:example.com |
https://example.com/.well-known/did.json |
did:web:example.com:agents:finance |
https://example.com/agents/finance/did.json |
did:key:z... |
The key encoded in the DID itself; no network access |
spiffe://example.com/agents/finance |
The SVID in the claim, validated against a configured trust bundle |
ans://v1.0.0.agent.example.com |
The identity certificate in the claim, once the agent's transparency log attests it |
Any other scheme, such as http:// or mailto:, is not supported and fails the claim.
DNS
Publish one TXT record per key at _agntcy-key.<domain>:
_agntcy-key.example.com. 300 IN TXT "v=akv1;key=<base64 DER SubjectPublicKeyInfo>"
The value is the standard base64 of the key's DER SubjectPublicKeyInfo, which is what
openssl pkey -pubout -outform DER produces. Records that are not a well-formed akv1 record
are skipped.
HTTPS and JWKS
For an https:// subject the verifier fetches /.well-known/jwks.json from the subject's
host, over HTTPS only, and uses every key in the JWK set.
A subject with a user-info part, such as https://example.com@evil.example, is refused.
DID
For did:web the document is fetched from the location the did:web method
defines. A document is only accepted when its id equals the subject. Only keys listed under
assertionMethod count, whether embedded or referenced from verificationMethod; a key that
the document lists only as key material is not authorized to make assertions. Keys are read from
publicKeyJwk.
A did:key subject carries its key, so it needs no network access. P-256, P-384, Ed25519 and
RSA keys are supported.
SPIFFE
A spiffe:// claim carries the signer's X.509-SVID in certificate, and no key is published
anywhere. The verifier accepts the claim only when the certificate fulfils the following conditions:
- Chains to the trust bundle configured for the subject's trust domain.
- Has a URI SAN equal to the subject.
- Is within its validity period.
The claim carries only the leaf certificate, so its issuer must be a root in the bundle. A trust domain with no configured bundle fails. See Configuration for how to provide the bundles.
ANS
An ans:// subject is an agent registered with the Agent Name Service (ANS), written
ans://v<major>.<minor>.<patch>.<host> in exactly that spelling: a lowercase host and a
version without leading zeros. The claim carries the agent's identity certificate in
certificate, and no key is published anywhere. The verifier accepts the claim only when all
of the following hold:
- The certificate has a URI SAN equal to the subject, is within its validity period, and the claim's signature verifies against its key.
- The TXT record at
_ans-badge.<host>names the agent's transparency log, and that log is one of the configuredtrusted_log_hosts. The record has the formv=ans-badge1; version=v1.0.0; url=https://<log host>/v1/agents/<uuid>; one without aversionapplies to every version of the agent. The record is a pointer only; nothing in it is trusted. - The log's status token for the agent verifies with the log's signing keys, names the
subject, reports a status of
ACTIVE,WARNINGorDEPRECATED, and lists the certificate's SHA-256 fingerprint among the agent's valid identity certificates.
A verified result therefore means: the claim was signed by the key of the certificate it
carries; that certificate names the subject, is within its validity, and its fingerprint is
listed as a valid identity certificate in a status token signed by a trusted transparency
log for an agent whose name is the subject and whose status allows use. The certificate's
issuer chain is not checked, the DNS pointer is not trusted, and neither the log's receipt
nor its tree head is examined. With allow_unpinned_root_keys, the log's signing keys are
fetched over TLS from the trusted host, so the proof reduces to TLS plus the log's own
statement.
Revocation and renewal. Every run asks the log again, so a revoked or expired agent, or
a certificate the log no longer lists, fails on the next run that reaches the log: the log's
answer about a subject is reused for status_cache_ttl within a run and dropped when the next
run starts. A lookup that gets no answer keeps the stored result for
ans.stale_grace (default 24 hours) from the last run that reached the subject, as described
under Unreachable subjects: a publisher who makes their own
_ans-badge lookup fail can delay the effect of a revocation by that long, so an operator who
wants to fail closed sets ans.stale_grace to 0. A claim also
stops verifying when its certificate expires, whatever the log says, so the publisher has to
push a new claim after each certificate renewal.
Failures. What the publisher publishes is judged as it is. A subject that is not
canonical, a certificate that does not name it or is outside its validity, a _ans-badge
record that is missing, ambiguous or points outside trusted_log_hosts, or whose name does
not exist, and what the log states (a terminal status, a token for another agent, a
certificate it does not list, a 404, 401, 403 or any other answer that is not a 5xx, 408, 429
or a redirect) fail the claim for that run, whatever the stored result was. A lookup that gets
no answer, a DNS failure other than "no such name" or a trusted log that is down (a 5xx, 408
or 429 answer, a refused connection, a timeout, or a redirect, which the client never
follows), is treated as for every other scheme, with ans.stale_grace in place of the seven
days; see Unreachable subjects. The stored error names the step that
failed, ans name, ans certificate, ans badge or ans log, after the reconciler's own
resolve keys of <subject>: prefix; a claim whose budget ran out while another claim's lookup
of the same subject was under way stores ans: waiting for the lookup of <subject>.
The log's keys. root_keys pins the logs' signing keys as the lines their /root-keys
endpoint serves, and may list several: pin a log's next key beside its current one before
the log signs with it, and the rotation has no gap. The keys are read at startup. A token
signed by a key that is not pinned fails the claim, fail closed: the stored error names the
key id, and the reconciler warns once per run until root_keys is updated and the reconciler
restarted. Alternatively allow_unpinned_root_keys fetches each log's keys from
/root-keys, refreshed every root_keys_ttl and on a rotation, and trust then rests on TLS
to the trusted hosts. The two settings cannot be combined. A malformed /root-keys answer
fails the claims of that log for one run.
Budget. A lookup gets timeout for the DNS record and timeout again for the log, so
one ans:// claim takes at most twice timeout, and record_timeout must allow for that.
The log's answer about a subject is reused for status_cache_ttl across the claims of that
subject, whatever certificates they carry, so a stack of claims attached to a record by
anyone costs one lookup. The memo is dropped when a run starts, so nothing carries over into
the next run.
The log is reached through the same SSRF-safe client as the other schemes, so a log on a
private address is refused at lookup time. Turning ans.enabled off, or running a
reconciler from before this scheme, fails ans:// claims as an unsupported scheme on the
next run.
At startup the reconciler logs the trusted logs and the pinned key ids; during a run a kept
result is logged at WARN (Keeping the last claim result) and counted as kept in the
run summary, so a log outage shows up as kept results. A key rotation the configuration has
not followed shows up as failed results naming the key id and one WARN per run (A trusted
transparency log signs with a key that identity.ans.root_keys does not hold).
Fetch limits
Documents are fetched with an SSRF-safe client, because the URL comes from untrusted record data. It speaks HTTPS only, refuses to connect to private, loopback, link-local and other non-public addresses (checked after DNS resolution), follows at most three redirects, and accepts responses of at most 1 MiB within a 10 second timeout. The ANS log client differs in two ways: it never follows a redirect, and it accepts at most 64 KiB.
Verification
The identity task of the reconciler checks claims on a fixed interval.
For each record that carries claims, and for each role, it does the following:
-
Selects the claims.
A claim whose
subjectis not the one the record declares for the role is skipped. Anyone can attach a claim to any record, so such a claim says nothing about the record, and it leaves no result behind. -
Runs the checks that need no key.
The claim is for this record (
recordCid), it is not expired,signedAtparses, and the certificate rule holds (a certificate if and only if the subject isspiffe://orans://). A claim that fails these costs no network lookup. -
Resolves the subject's current keys for its scheme.
- Verifies the signature against those keys.
If several claims of one role pass the selection, a verified claim decides the result,
whoever else attached claims. Otherwise the failure of the claim with the newest signedAt is
reported. A signedAt in the future, or one that does not parse, counts as the oldest, so a
forged date cannot outrank an honest claim.
Within one run a lookup is made once per subject, however many records share it.
Results
| State | Meaning |
|---|---|
no result |
No claim of this role has been verified yet, or the claim no longer applies |
verified |
A claim for the declared subject verified against the keys the subject publishes |
failed |
Every claim for the declared subject failed; the reason is kept (up to 1,024 characters) |
A failed result always names the subject the record declares, never one a claim chose. If a claim is withdrawn, or no claim applies any more, its stored result is removed. If a record's claims cannot be read at all, the stored results are left as they were.
Unreachable subjects
A failed lookup is not the same as a wrong claim. If the subject cannot be reached, because
of a timeout, a refused connection, a DNS failure other than "no such name", a 5xx, 408 or
429 response, or a redirect (which the client never follows), a result that is already stored
is left as it is, and nothing is written for that run. The result's age is counted from when
it was last written, so the grace lasts seven days from the last run that reached the subject.
After that, the next run that still cannot reach the subject stores a failed result with the
lookup error.
Two cases get no grace:
- A claim that has no stored result yet fails at once if its subject is unreachable on the first run, and verifies on the first run that reaches it.
- An answer that says the subject publishes no usable key, a
404, a "no such host" or a blocked address is not transient, so it fails the claim immediately. - A subject in a scheme the task does not serve, an
ans://subject withans.enabledoff, fails at once: a scheme the task cannot verify does not keep its results.
The seven days are fixed for every scheme but ans://, whose grace is ans.stale_grace
(default 24 hours; see ANS): the publisher's own DNS is among the lookups that may get
no answer, so a shorter window bounds how long a failing zone can hide a revocation, and 0
keeps nothing. A run that is stopped mid-lookup stores no failure for any scheme; the next
run starts over from the stored result.
Stored result
Each result is one row per record and role in the server database. A result is about the record's declared subject, not about one claim: the task only considers claims whose subject is the record's, whichever of them verifies decides the result, and a result kept through the grace stands whatever claims are attached meanwhile, so a claim re-pushed for the same subject, after a certificate renewal for instance, inherits it.
| Column | Description |
|---|---|
record_cid |
Record the claim is about (primary key with role) |
role |
identity or owner |
subject |
The subject the record declares |
status |
verified or failed |
error |
Reason for a failure |
verified_at |
When the claim was last checked |
Reading the results
IdentityService
GetIdentityStatus returns the last result of a record's claims. A claim that has not been
verified is left unset, which is not an error. An unknown record is NotFound.
| Message | Fields |
|---|---|
GetIdentityStatusRequest |
cid, or name with an optional version |
GetIdentityStatusResponse |
identity, owner (each an optional ClaimVerification) |
ClaimVerification |
role, subject, status, error (only when failed), verified_at |
ClaimVerificationStatus is CLAIM_VERIFICATION_STATUS_VERIFIED or
CLAIM_VERIFICATION_STATUS_FAILED.
Resolve turns a record name, with an optional version, into the CIDs of the matching
records, newest first. It is read-only like the rest of the service: results are only written
by the reconciler.
CLI and SDK
# Create the claim, then read the result
dirctl identity claim --record <cid> --role owner --key owner.key
dirctl identity status <cid> --output json
# Resolve a name
dirctl identity resolve example.com/agents/research-assistant
In Go, ClaimIdentity and ClaimOwnership create claims, and GetIdentityStatus and
Resolve read results; see the Go SDK.
Search
Records can be searched by the subject of their claims and by whether a claim verified:
dirctl search --owner 'dns:example.com' # by subject (wildcards allowed)
dirctl search --identity 'did:web:example.com:*'
dirctl search --owner-verified # owner claim verified
dirctl search --identity-verified # identity claim verified
dirctl search --verified # same as --owner-verified
dirctl search --trusted --verified # signed and owner-verified
--identity-verified and --owner-verified only filter when set. --verified is tri-state:
--verified=false matches records without a verified owner. A subject filter matches the
subject the record declares. See
CLI Reference — Search for all flags.
Configuration
Claim verification is a reconciler task and is disabled by default. The dirctl daemon
default configuration enables it.
reconciler:
identity:
enabled: true
interval: 1h # how often every claim is verified again
record_timeout: 1m # time allowed for all the claims of one record
spiffe_trust_bundles:
- trust_domain: example.org
bundle_file: /etc/agntcy/spiffe/example.org.pem
ans:
enabled: true
trusted_log_hosts:
- log.example.com
root_keys:
- "example-log+1a2b3c4d+AjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABF..."
timeout: 10s
status_cache_ttl: 30s
stale_grace: 24h
| Key | Environment variable | Default | Description |
|---|---|---|---|
enabled |
RECONCILER_IDENTITY_ENABLED |
false |
Run the task |
interval |
RECONCILER_IDENTITY_INTERVAL |
1h |
Time between runs |
record_timeout |
RECONCILER_IDENTITY_RECORD_TIMEOUT |
1m |
Time allowed for one record |
spiffe_trust_bundles |
none, YAML only | empty | Trust bundle per SPIFFE trust domain |
ans.enabled |
RECONCILER_IDENTITY_ANS_ENABLED |
false |
Verify ans:// claims |
ans.trusted_log_hosts |
RECONCILER_IDENTITY_ANS_TRUSTED_LOG_HOSTS, comma-separated |
empty | Transparency-log hosts (host or host:port) a badge record may point at |
ans.root_keys |
RECONCILER_IDENTITY_ANS_ROOT_KEYS, comma-separated |
empty | The logs' signing keys, as the lines their /root-keys endpoint serves; required unless unpinned |
ans.allow_unpinned_root_keys |
RECONCILER_IDENTITY_ANS_ALLOW_UNPINNED_ROOT_KEYS |
false |
Fetch each log's keys from /root-keys instead; cannot be combined with root_keys |
ans.root_keys_ttl |
RECONCILER_IDENTITY_ANS_ROOT_KEYS_TTL |
10m |
How long fetched keys are used before they are fetched again |
ans.status_cache_ttl |
RECONCILER_IDENTITY_ANS_STATUS_CACHE_TTL |
30s |
How long a subject's attestation is reused within a run; at least 5s |
ans.stale_grace |
RECONCILER_IDENTITY_ANS_STALE_GRACE |
24h |
How long a stored result survives lookups that get no answer; 0 keeps nothing |
ans.timeout |
RECONCILER_IDENTITY_ANS_TIMEOUT |
10s |
Time allowed for the DNS record, and again for the log; record_timeout must be at least four times this |
ans.clock_skew |
RECONCILER_IDENTITY_ANS_CLOCK_SKEW |
30s |
Tolerance on the status token's expiry, at most 10m |
Each spiffe_trust_bundles entry has a trust_domain and a bundle_file holding the trust
domain's PEM root certificates. The files are read on every run, so a rotated or revoked bundle
applies on the next one. A bundle that cannot be read only fails the claims of its own trust
domain; the other domains and the run are not affected. With no bundle, spiffe:// claims fail.
The ans block is checked at startup: a malformed host or root-key line, both root_keys and
allow_unpinned_root_keys, a timeout that does not fit record_timeout (a record may carry
an identity and an ownership claim, each taking two timeouts), a status_cache_ttl below 5s,
or a negative stale_grace stops the reconciler from starting, and under
dirctl daemon the API server with it. Under dirctl daemon the same keys take the
DIRECTORY_DAEMON_RECONCILER_IDENTITY_ANS_ prefix.
Turning ans.enabled off is a rollback, not a pause: on the next run every ans:// claim
fails as an unsupported scheme, with no grace, and the next run after the block is turned back
on verifies them again. stale_grace unset means the default 24h; 0 keeps nothing.
Security properties
- Anyone who can push a referrer can attach a claim to any record, so a result only comes from a claim that names the declared subject and verifies against that subject's own keys.
- The declared subject is part of the content-addressed record, so a claim cannot change who the record says it is about.
- The record CID and the role are signed, so a claim cannot be replayed against another record or as the other role.
- The signature algorithm is derived from the key, not from the claim, and RSA keys below 2048 bits are rejected.
- A verified claim decides the result, whatever else is attached to the record.
- Claims are verified again on every run, so key rotation, DNS changes, a revoked trust bundle,
a revoked ANS agent and expiry take effect on the next run that reaches the subject. A lookup
that gets no answer keeps the stored result for the grace under
Unreachable subjects: seven days, or
ans.stale_graceforans://, which an operator sets to0to fail closed. - Keys are fetched with an SSRF-safe client, because the URLs come from record data.