Bring your own key
Level 3. Generate an Ed25519 key, register only the public key, prove possession, then sign every request.
With level 3 the private key never leaves your system. AY registers the public key as #dev-1 in the agent's DID document and asks you to prove possession before the agent becomes active.
Before you begin
- An OIDC token of the owner account. The acting person must have paired the AY app (with
X-Act-As: the person acting for the business); otherwise registration, attestation and rotation answer409 IDENTITY_NOT_REGISTERED. - Node.js 22 with
@noble/curvesand@scure/base, or an equivalent Ed25519 library. - A key store for the private key. A key that leaks cannot be recalled; it can only be rotated.
Generate the key
import { ed25519 } from '@noble/curves/ed25519';
import { base58 } from '@scure/base';
const priv = ed25519.utils.randomPrivateKey(); // 32 bytes, keep secret
const pub = ed25519.getPublicKey(priv); // 32 bytes
const multikey = 'z' + base58.encode(new Uint8Array([0xed, 0x01, ...pub]));
// multikey looks like z6Mk…The Multikey form is the 0xed01 prefix plus the 32 public-key bytes, base58btc encoded, with a leading z. AY accepts values that match ^z[1-9A-HJ-NP-Za-km-z]{40,60}$ and decode to an Ed25519 key.
Register with the public key
curl -X POST https://api.aytwin.ai/api/v1/agents \
-H "Authorization: Bearer $AY_OIDC_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Enterprise Agent", "service_endpoint": "https://agent.example.com/a2a", "public_key_multibase": "z6Mk…"}'The response has status: "pending_attestation", no credential, no API key, and no passport yet. It contains the challenge:
"attestation": {
"challenge": "b64url-of-32-random-bytes",
"expires_at": "2026-10-01T12:10:00.000Z",
"dev_key": "did:webvh:…:agents:…#dev-1",
"attest_url": "https://api.aytwin.ai/api/v1/agents/…/attest"
}The challenge is valid for ten minutes. The key is already in the DID document under verificationMethod, authentication, and assertionMethod.
Prove possession
Decode the challenge from base64url, sign the raw bytes with the private key, and send the signature as base64url together with the challenge you received.
const challengeBytes = Buffer.from(attestation.challenge, 'base64url');
const signature = Buffer.from(ed25519.sign(challengeBytes, priv)).toString('base64url');curl -X POST "$ATTEST_URL" \
-H "Authorization: Bearer $AY_OIDC_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"challenge\": \"$CHALLENGE\", \"signature\": \"$SIGNATURE\"}"On success the agent is active, key_custody is developer, and the response carries the Agent Passport with the dev_key claim.
| Status | Code | Meaning |
|---|---|---|
| 422 | attestation_invalid | The challenge does not match or the signature does not verify. |
| 410 | attestation_expired | Ten minutes passed. Register again. |
| 409 | ATTESTATION_NOT_PENDING | The agent is not waiting for a proof. |
The web app performs this step for you if you register there and paste the signature.
Sign every request
From now on the agent authenticates with an RFC 9421 signature, tag ay-agent, keyid = the current dev_key. No bearer token is sent.
import { createSignature } from 'http-message-sig';
import { createHash, randomBytes } from 'node:crypto';
const body = new Uint8Array(); // GET: empty body
const digest = `sha-256=:${createHash('sha256').update(body).digest('base64')}:`;
const created = Math.floor(Date.now() / 1000);
const fields = await createSignature(
{ kind: 'request', method: 'GET', targetUri: 'https://api.aytwin.ai/api/v1/agents/me', fields: [{ name: 'content-digest', value: digest }] },
{
label: 'sig1',
components: ['@method', '@authority', '@path', 'content-digest'],
parameters: { created, expires: created + 300, nonce: randomBytes(16).toString('base64url'), keyid: devKey, tag: 'ay-agent', alg: 'ed25519' },
signer: { algorithm: 'ed25519', sign: (data) => ed25519.sign(data, priv) },
},
);
const res = await fetch('https://api.aytwin.ai/api/v1/agents/me', {
headers: { 'Content-Digest': digest, 'Signature-Input': fields.signatureInput, 'Signature': fields.signature },
});The verifier consumes the nonce: sending the same signature twice returns 401 signature_invalid with reason replay. Make a fresh signature for every attempt, including retries. See Signed requests for the full profile and Test vectors to check your bytes.
What is different at level 3
- No Zitadel credential exists for the agent, so
POST /agents/{id}/credentials/rotatereturns409 AGENT_CREDENTIAL_NOT_ROTATABLE. - The Agent Card advertises
httpSignatureand is unsigned by AY. Host your own signed card if your integration needs one. - The agent can rotate its own key without the owner: see Rotate keys and credentials.