This guide covers the security model, threat mitigations, and operational best practices for AgentPin deployments.
AgentPin uses ES256 (ECDSA with P-256) exclusively. All other algorithms are rejected.
| Property | Value |
|---|---|
| Signing Algorithm | ECDSA (Elliptic Curve Digital Signature Algorithm) |
| Curve | P-256 (secp256r1) |
| Hash | SHA-256 |
| Key Format | JWK (RFC 7517) for public keys, PEM for private keys |
| Credential Format | JWT (compact serialization) |
| Signature Encoding | DER-encoded (cross-language compatible) |
Single-algorithm enforcement prevents:
- Algorithm confusion attacks — Attacker substitutes
noneorHS256to bypass verification - Downgrade attacks — Attacker forces weaker algorithm selection
- Implementation complexity — Fewer code paths means fewer bugs
All four implementations (Rust, Go, JavaScript, Python) validate alg: "ES256" and typ: "agentpin-credential+jwt" before any other processing, and then validate the claim shape (MALFORMED_CREDENTIAL) before any cryptography.
Every object AgentPin signs other than the credential JWT itself — challenge responses, delegation attestations, A2A AgentCards, signed revocation documents and signed trust bundles — is signed over a domain-separated input:
<context> || 0x00 || (u32_be(len(field)) || field)*
with a fixed context label per object type (agentpin-response-v1, agentpin-attestation-v1, agentpin-agentcard-v1, agentpin-revocation-v1, agentpin-bundle-v1). The JWT keeps its RFC 7515 header.payload input, whose alphabet can never contain the NUL byte that terminates every context label, so a signature produced for one object type can never be replayed as another. In particular, answering a mutual-authentication challenge can never yield a credential signature: the responder also refuses any nonce that is not exactly 22 canonical base64url characters.
- Never commit private keys to version control
- Never embed private keys in application code or environment variables in plaintext
- Store private keys in secure storage: file system with restricted permissions, HSM, or secrets manager
- Use separate keys for separate environments (dev, staging, production)
# keygen creates private keys with mode 0600; verify after copying them elsewhere
ls -la ./keys/*.private.pem
# -rw------- 1 agentpin agentpin 227 Feb 15 2026 example-2026-01.private.pemRotate keys regularly. Recommended schedule:
| Key Type | Rotation Period |
|---|---|
| Production signing keys | Every 6-12 months |
| Development/testing keys | Every 3 months |
| Keys after suspected compromise | Immediately |
Rotation procedure:
- Generate new key pair (
agentpin keygencreates the private key with mode0600and refuses to overwrite an existing key unless--force) - Add new public key to discovery document
- Reduce discovery document cache TTL during transition
- Begin issuing credentials with new key
- After grace period, revoke old key
- Remove old key from discovery document
Use descriptive, time-stamped key IDs for traceability:
{domain}-{year}-{sequence}
Examples:
example-2026-01 # First key for 2026
example-2026-02 # Second key (after rotation)
staging-2026-01 # Staging environment key
Issue credentials with the shortest practical TTL:
| Use Case | Recommended TTL |
|---|---|
| Single API call | 300 seconds (5 min) |
| Session | 3600 seconds (1 hour) |
| Long-running task | 14400 seconds (4 hours) |
| Maximum allowed | 86400 seconds (24 hours) |
# Prefer short TTLs
credential = issue_credential(
private_key_pem=key,
kid="example-2026-01",
issuer="example.com",
agent_id="urn:agentpin:example.com:agent",
audience="verifier.com",
capabilities=[Capability.create("read", "data")],
ttl_secs=3600, # 1 hour — not 86400
)Always specify an audience (aud claim) to prevent credential replay at unintended verifiers:
const credential = issueCredential(
privateKey, kid, 'example.com',
'urn:agentpin:example.com:agent',
'verifier.com', // Bind to specific verifier
capabilities, null, null, 3600,
);Verifiers should check the audience:
const result = verifyCredentialOffline(
credential, discovery, null, pinStore,
'verifier.com', // Reject if aud doesn't match
);Follow the principle of least privilege — issue credentials with only the capabilities needed:
# Good: Minimal capabilities for the task
credential = issue_credential(
...,
capabilities=[Capability.create("read", "reports")],
ttl_secs=300,
)
# Bad: Overly broad capabilities
credential = issue_credential(
...,
capabilities=[Capability.create("read", "*"), Capability.create("write", "*")],
ttl_secs=86400,
)Trust-On-First-Use pinning protects against key substitution attacks after initial verification.
- First verification for a domain: public key fingerprint (JWK thumbprint per RFC 7638) is stored under the normalized domain name (lowercase, trailing dot removed, IDNA A-labels) —
EXAMPLE.COMandexample.comshare one pin - Subsequent verifications: key must match the stored fingerprint
- Key change detected: verification fails with
KEY_PIN_MISMATCH
Always persist the pin store between sessions:
import os
from agentpin import KeyPinStore
PIN_FILE = "/var/lib/agentpin/pins.json"
# Load existing pins
if os.path.exists(PIN_FILE):
pin_store = KeyPinStore.from_json(open(PIN_FILE).read())
else:
pin_store = KeyPinStore()
# Use for verification
result = verify_credential_offline(jwt, discovery, None, pin_store, audience)
# Save updated pins
with open(PIN_FILE, "w") as f:
f.write(pin_store.to_json())A KEY_PIN_MISMATCH failure requires investigation:
if not result.valid and result.error_code == ErrorCode.KEY_PIN_MISMATCH:
# This is a security event — do not silently accept
log_security_event(
event="key_pin_mismatch",
domain=payload_issuer,
action="verification_rejected",
)
# Require manual approval before accepting the new keyOn success result.key_pinning is an object: {"status": "first_use" | "pinned", "first_seen": ...}.
Legitimate key changes (rotation) should be communicated out-of-band. Update the pin store explicitly:
const pinStore = KeyPinStore.fromJson(existingPins);
// After confirming legitimate rotation:
pinStore.addKey(domain, newJwk);Threat: A malicious agent claims to be a trusted agent.
Mitigation: Cryptographic verification — the agent must present a JWT signed by a key declared in the issuer's discovery document. Without the private key, impersonation is impossible.
Threat: An agent claims authorization from an operator who never granted it.
Mitigation: Delegation chains require a Maker attestation — a cryptographic signature from the Maker over the domain-separated attestation input (attester domain and key id, role, agent id, delegatee domain and agent id, capabilities hash, optional expiry). Every verifier resolves the attester's discovery document, verifies the signature, checks that the credential's capabilities stay within what the attester declared for the agent, and bounds the chain depth. A chain that cannot be verified is rejected by default (require_delegation_verification); the Deployer cannot forge an attestation without the Maker's private key.
Threat: An agent claims capabilities beyond what it was authorized.
Mitigation: Capability validation (Step 10) checks that credential capabilities are a subset of capabilities declared in the discovery document. Delegation chains enforce capability narrowing at each level.
Threat: An attacker modifies the discovery document in transit or at the server.
Mitigation:
- HTTPS provides transport-layer integrity
- TOFU pinning detects key changes after initial verification
- Redirect rejection prevents redirect-based attacks
revocation_endpointanda2a_endpointmust behttps://URLs on the entity's own host; anything else makes the document invalid
Threat: An attacker captures and replays a valid credential.
Mitigation:
- Short-lived credentials minimize the replay window
- Audience binding prevents replay at unintended verifiers
- JTI (JWT ID) enables one-time-use verification
- Nonce-based mutual authentication for real-time verification
Threat: An attacker obtains a private key.
Mitigation:
- Revocation at credential, agent, and key levels
- Short credential TTLs limit exposure window; verifiers enforce both their own cap and the agent's declared
credential_ttl_max - TOFU pinning detects key substitution at verifiers that already pinned the legitimate key
KeyPairzeroizes the private PEM on drop and redacts it fromDebugoutput (Rust)
| Level | Scope | Use Case |
|---|---|---|
| Credential | Single JWT (by jti) |
Compromised individual credential |
| Agent | All credentials for an agent | Agent decommissioned or compromised |
| Key | All credentials signed by a key | Key compromised |
Publish at /.well-known/agent-identity-revocations.json with a short cache TTL (5 minutes):
{
"agentpin_version": "0.1",
"entity": "example.com",
"revoked_credentials": [
{ "jti": "jti-123", "reason": "key_compromise", "revoked_at": "2026-02-15T00:00:00Z" }
],
"revoked_agents": [
{ "agent_id": "urn:agentpin:example.com:retired-agent", "reason": "cessation_of_operation", "revoked_at": "2026-02-15T00:00:00Z" }
],
"revoked_keys": [
{ "kid": "example-2025-01", "reason": "key_compromise", "revoked_at": "2026-02-15T00:00:00Z" }
],
"updated_at": "2026-02-15T00:00:00Z"
}The identifying fields are jti (credentials), agent_id (agents) and kid (keys), exactly as in spec §8.2. Every SDK validates the document structurally before using it: an entry that lacks its identifying field makes the whole document invalid and the credential is rejected with DISCOVERY_INVALID, so a mis-published schema can never silently disable revocation.
Optionally sign the document with one of the discovery-document keys (sign_revocation_document / signRevocationDocument / SignRevocationDocument); it then carries kid and signature fields and every verifier checks the signature against the discovery keys. Verifiers can require this with require_signed_revocation and bound staleness with revocation_max_age_secs.
Verifiers MUST check revocation on every verification, regardless of discovery document cache state. When the discovery document declares no revocation_endpoint, verifiers fetch the default https://{domain}/.well-known/agent-identity-revocations.json; a 404 there means no revocations are published, any other failure rejects the credential (fail-closed).
- Discovery documents MUST be served over HTTPS
- TLS certificates MUST be valid (not expired, not self-signed in production)
- HTTP redirects MUST NOT be followed during discovery, revocation or AgentCard fetching
- Every fetch is bounded: 10 s timeout, 1 MiB body cap (both by
Content-Lengthand while streaming), JSONContent-Typerequired issis normalized and must be a bare hostname: a value with a scheme, port, path, userinfo or IP literal is rejected before any request is sent
AgentPin rejects HTTP redirects to prevent:
- Redirect to attacker-controlled server serving malicious discovery document
- Redirect loops causing denial of service
- Open redirect exploitation
// Correct: reject redirects
const response = await fetch(url, { redirect: 'error' });
// Incorrect: following redirects
const response = await fetch(url, { redirect: 'follow' }); // DO NOT DO THISProtect discovery endpoints from abuse:
# nginx rate limiting for AgentPin endpoints
limit_req_zone $binary_remote_addr zone=agentpin:10m rate=10r/s;
location /.well-known/agent-identity.json {
limit_req zone=agentpin burst=20 nodelay;
# ... other config
}All four AgentPin implementations (Rust, Go, JavaScript, Python) produce interoperable credentials:
- All use DER-encoded ECDSA signatures (deliberately not RFC 7515 raw
r||s; see spec §5.1) - All use identical JSON field names and strict, unpadded base64url
- A credential issued by one language can be verified by any other
- The shared
testdata/conformance/vectors are loaded by all four test suites and include must-reject cases
Verify cross-language compatibility in your test suite:
# Issue with Python, verify with JavaScript
python -c "from agentpin import ...; print(issue_credential(...))" | \
node -e "const { verifyCredentialOffline } = require('agentpin'); ..."