AFV2 · Ed25519 challenge response
Verify a registered agent key.
AF Verify binds a one-time challenge to your organization, project, origin, action, audience, resource, scopes, and business context—then issues a short-lived EdDSA token.
Five-minute integration
Quickstart
Use the dashboard to create a project, register an allowed browser origin, add an agent’s Ed25519 public key, and create an API key.
- Create a project. Registration creates a test project, default API key, and
http://localhost:3000origin. - Register the agent public key. AF Verify accepts Ed25519 SPKI PEM. The private key never leaves the agent runtime.
- Embed the widget. Put only the public site key in browser markup.
- Let the agent sign. The agent fetches the challenge, enforces its own policy, and submits an Ed25519 signature.
- Redeem the token on your server. Call
/v1/siteverifywith the secret API key before authorizing the requested action.
Credential types
Authentication
| Credential | Where it belongs | Purpose |
|---|---|---|
public_site_key | Browser widget | Identifies the project. Exact Origin allowlisting still applies. |
secret_api_key | Server or secrets manager | Bearer credential for token verification. Displayed only once. |
| Agent Ed25519 private key | Agent runtime or hardware-backed vault | Signs AFV2 challenges. Never uploaded to AF Verify. |
| Dashboard session + CSRF token | AF Verify dashboard | Organization and project management. Not an integration credential. |
Public browser authentication
Origin: https://app.example.com
X-AF-Site-Key: afv_pk_test_...Server authentication
Authorization: Bearer afv_sk_test_...Rotate an API key by creating a replacement, deploying it, confirming recent use, and revoking the old key. Revocation is immediate.
Step 01
Create a challenge
/api/v1/challengesA browser must send a registered Origin and a public site key with the challenge:create scope.
curl https://af.mayoflux.tv/api/v1/challenges \
--request POST \
--header "Origin: https://app.example.com" \
--header "X-AF-Site-Key: $AF_SITE_KEY" \
--header "Content-Type: application/json" \
--data '{
"action": "orders:approve",
"audience": "orders-api",
"resource": "https://api.example.com/orders/ord_123",
"scopes": ["orders:approve"],
"context_sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"requested_agent_id": "agt_..."
}'requested_agent_id is optional. When supplied, the challenge is restricted to that active agent and its registered policy is evaluated before issuance.
{
"object": "af_verify.challenge",
"challenge": {
"protocol": "AFV2",
"issuer": "https://af.mayoflux.tv",
"id": "afc_...",
"organization_id": "org_...",
"project_id": "prj_...",
"nonce": "...",
"site_key": "afv_pk_test_...",
"origin": "https://app.example.com",
"action": "orders:approve",
"audience": "orders-api",
"resource": "https://api.example.com/orders/ord_123",
"scopes": ["orders:approve"],
"context_sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"issued_at_unix": 1784167620,
"expires_at_unix": 1784167680
},
"agent_url": "https://af.mayoflux.tv/v1/challenges/afc_...",
"verify_url": "https://af.mayoflux.tv/v1/challenges/afc_.../verify",
"status_url": "https://af.mayoflux.tv/v1/challenges/afc_.../status",
"poll_token": "..."
}The challenge normally expires after 60 seconds. Treat the poll token as a temporary capability and avoid logging it.
Step 02
Build the canonical AFV2 message
The agent signs the exact UTF-8 bytes below. Text fields are unpadded base64url. Scopes are unique, sorted strings serialized as compact JSON. There is no trailing newline.
AFV2
issuer_b64=<base64url(issuer UTF-8)>
challenge_id=<challenge.id>
organization_id=<organization_id>
project_id=<project_id>
site_key=<site_key>
nonce=<nonce>
origin_b64=<base64url(origin UTF-8)>
action_b64=<base64url(action UTF-8)>
audience_b64=<base64url(audience UTF-8)>
resource_b64=<base64url(resource UTF-8)>
scopes_b64=<base64url(JSON.stringify(sorted_scopes) UTF-8)>
context_sha256=<64 lowercase hex>
issued_at_unix=<integer>
expires_at_unix=<integer>
agent_id=<registered agent id>
key_id=<registered key id>Step 03
Sign and submit the agent proof
The agent should first verify the issuer, expiry, audience, resource, scopes, origin, and action against its local policy.
Node.js agent signer
import { readFile } from 'node:fs/promises';
import { sign } from 'node:crypto';
const agentId = process.env.AF_AGENT_ID;
const keyId = process.env.AF_AGENT_KEY_ID;
const challengeUrl = process.argv[2];
const envelope = await fetch(challengeUrl).then(async (response) => {
if (!response.ok) throw new Error(`Challenge failed: ${response.status}`);
return response.json();
});
const c = envelope.challenge;
if (c.issuer !== 'https://af.mayoflux.tv') throw new Error('Untrusted issuer');
if (c.expires_at_unix <= Math.floor(Date.now() / 1000)) throw new Error('Expired');
if (c.audience !== 'orders-api') throw new Error('Audience not allowed');
if (c.action !== 'orders:approve') throw new Error('Action not allowed');
const b64 = (value) => Buffer.from(value, 'utf8').toString('base64url');
const scopes = [...new Set(c.scopes.map((value) => String(value).trim()))].sort();
const canonical = [
'AFV2',
`issuer_b64=${b64(c.issuer)}`,
`challenge_id=${c.id}`,
`organization_id=${c.organization_id}`,
`project_id=${c.project_id}`,
`site_key=${c.site_key}`,
`nonce=${c.nonce}`,
`origin_b64=${b64(c.origin)}`,
`action_b64=${b64(c.action)}`,
`audience_b64=${b64(c.audience)}`,
`resource_b64=${b64(c.resource)}`,
`scopes_b64=${b64(JSON.stringify(scopes))}`,
`context_sha256=${c.context_sha256}`,
`issued_at_unix=${c.issued_at_unix}`,
`expires_at_unix=${c.expires_at_unix}`,
`agent_id=${agentId}`,
`key_id=${keyId}`,
].join('\n');
const privateKey = await readFile(process.env.AF_AGENT_PRIVATE_KEY);
const signature = sign(null, Buffer.from(canonical, 'utf8'), privateKey)
.toString('base64url');
const result = await fetch('https://af.mayoflux.tv/api/v1/verifications', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge_id: c.id,
agent_id: agentId,
key_id: keyId,
signature,
algorithm: 'Ed25519',
}),
});
console.log(await result.json());Python agent signer
import base64
import json
import os
import time
import requests
from cryptography.hazmat.primitives.serialization import load_pem_private_key
def b64(value: str) -> str:
return base64.urlsafe_b64encode(value.encode()).rstrip(b"=").decode()
agent_id = os.environ["AF_AGENT_ID"]
key_id = os.environ["AF_AGENT_KEY_ID"]
envelope = requests.get(os.environ["AF_CHALLENGE_URL"], timeout=10).json()
c = envelope["challenge"]
if c["issuer"] != "https://af.mayoflux.tv": raise RuntimeError("Untrusted issuer")
if c["expires_at_unix"] <= int(time.time()): raise RuntimeError("Expired")
scopes = sorted(set(str(value).strip() for value in c["scopes"]))
canonical = "\n".join([
"AFV2",
f"issuer_b64={b64(c['issuer'])}",
f"challenge_id={c['id']}",
f"organization_id={c['organization_id']}",
f"project_id={c['project_id']}",
f"site_key={c['site_key']}",
f"nonce={c['nonce']}",
f"origin_b64={b64(c['origin'])}",
f"action_b64={b64(c['action'])}",
f"audience_b64={b64(c['audience'])}",
f"resource_b64={b64(c['resource'])}",
f"scopes_b64={b64(json.dumps(scopes, separators=(',', ':')))}",
f"context_sha256={c['context_sha256']}",
f"issued_at_unix={c['issued_at_unix']}",
f"expires_at_unix={c['expires_at_unix']}",
f"agent_id={agent_id}",
f"key_id={key_id}",
])
with open(os.environ["AF_AGENT_PRIVATE_KEY"], "rb") as key_file:
private_key = load_pem_private_key(key_file.read(), password=None)
signature = base64.urlsafe_b64encode(
private_key.sign(canonical.encode())
).rstrip(b"=").decode()
result = requests.post(
"https://af.mayoflux.tv/api/v1/verifications",
json={"challenge_id": c["id"], "agent_id": agent_id,
"key_id": key_id, "signature": signature,
"algorithm": "Ed25519"},
timeout=10,
)
print(result.json())/api/v1/verificationsSuccessful verification atomically consumes the challenge and returns pass_token, a short-lived EdDSA JWT. A challenge permits at most five proof attempts.
Step 04
Verify or redeem the pass token
Single-use site verification
/v1/siteverifyThis server-only endpoint consumes a valid token by default. Always send the expected business values instead of accepting token claims without comparison.
curl https://af.mayoflux.tv/v1/siteverify \
--request POST \
--header "Authorization: Bearer $AF_SECRET_KEY" \
--header "Content-Type: application/json" \
--data "{
\"response\": \"$AF_PASS_TOKEN\",
\"expected_action\": \"orders:approve\",
\"expected_origin\": \"https://app.example.com\",
\"expected_audience\": \"orders-api\",
\"expected_resource\": \"https://api.example.com/orders/ord_123\",
\"required_scopes\": [\"orders:approve\"],
\"expected_context_sha256\": \"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"
}"{
"active": true,
"success": true,
"protocol": "AFV2",
"challenge_id": "afc_...",
"agent_id": "agt_...",
"agent_key_id": "agk_...",
"site_key": "afv_pk_test_...",
"origin": "https://app.example.com",
"action": "orders:approve",
"audience": "orders-api",
"resource": "https://api.example.com/orders/ord_123",
"scopes": ["orders:approve"],
"assurance": "registered-agent-key",
"consumed": true
}Non-consuming introspection
/api/v1/tokens/introspectThe introspection endpoint uses the same response field and defaults to consume: false. Set consume: true only when you want atomic redemption. Inactive tokens return HTTP 200 with active: false; replay conflicts return HTTP 409.
Browser integration
Embed the live widget
<script defer src="https://af.mayoflux.tv/widget/v1/af-verify.js"></script>
<form action="/approve-order" method="post">
<div
class="af-verify"
data-sitekey="afv_pk_test_..."
data-action="orders:approve"
data-audience="orders-api"
data-resource="https://api.example.com/orders/ord_123"
data-scopes="orders:approve"
data-context-sha256="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
data-requested-agent-id="agt_..."
data-response-field="af-response">
</div>
<button type="submit">Approve order</button>
</form>On success the widget creates a hidden af-response input containing the pass token. Redeem it on your server; the browser event is not an authorization decision.
const widget = document.querySelector('.af-verify');
widget.addEventListener('af:challenge', ({ detail }) => {
console.log(detail.challenge_id, detail.agent_url, detail.expires_at_unix);
});
widget.addEventListener('af:verified', ({ detail }) => {
console.log('Pass ready for server verification', detail.assurance);
});
widget.addEventListener('af:error', ({ detail }) => {
console.error(detail.code, detail.message);
});const result = await AFVerify.open({
siteKey: 'afv_pk_test_...',
action: 'orders:approve',
audience: 'orders-api',
resource: 'https://api.example.com/orders/ord_123',
scopes: ['orders:approve'],
requestedAgentId: 'agt_...'
});
if (result.verified) {
// Send result.pass_token to your backend for redemption.
}Use AFVerify.render(target, options) for dynamic inline mounts, AFVerify.open(options) for a modal, or AFVerify.scan() after inserting declarative widgets. Add data-theme="light" for the light palette.
https://af.mayoflux.tv in script-src, style-src, and connect-src. The widget uses external stylesheets and does not require 'unsafe-inline'. If application code calls AFVerify.open(), load that code after the deferred widget script or from its load event.Standards-based tokens
Local JWT verification
AF Verify tokens use alg: EdDSA, typ: AFV+JWT, and a service signing-key kid. Public keys are published at /.well-known/jwks.json.
import { createRemoteJWKSet, jwtVerify } from 'jose';
const jwks = createRemoteJWKSet(
new URL('https://af.mayoflux.tv/.well-known/jwks.json')
);
const { payload, protectedHeader } = await jwtVerify(token, jwks, {
algorithms: ['EdDSA'],
issuer: 'https://af.mayoflux.tv',
audience: 'orders-api',
clockTolerance: 5,
});
if (protectedHeader.typ !== 'AFV+JWT') throw new Error('Wrong token type');
if (payload.azp !== process.env.AF_SITE_KEY) throw new Error('Wrong site key');
if (payload.ver !== 'AFV2') throw new Error('Wrong protocol');
if (payload.action !== 'orders:approve') throw new Error('Wrong action');
if (payload.resource !== expectedResource) throw new Error('Wrong resource');
if (!payload.scopes.includes('orders:approve')) throw new Error('Missing scope');
if (payload.assurance !== 'registered-agent-key') throw new Error('Wrong assurance');/v1/siteverify or maintain a durable replay cache keyed by jti.Current delivery model
Webhooks and polling
status_url with X-AF-Poll-Token once per second until verification or expiry./v1/challenges/:id/statuscurl "$AF_STATUS_URL" \
--header "X-AF-Poll-Token: $AF_POLL_TOKEN"Keep the poll token in the initiating browser session. Do not use it as an API secret or persist it after the challenge expires.
Management plane
Dashboard sessions and roles
The management API uses an HTTP-only session cookie plus X-CSRF-Token on mutations. It is separate from the public verification API.
| Role | Project access |
|---|---|
owner | Full organization and project management. |
admin | Organization membership and project management. |
developer | Create projects and manage agents, keys, and origins. |
analyst, billing, read_only | Read project data; mutations remain server-denied. |
system_admin | Separate operator-controlled flag for the platform access-application queue. Registration never grants it from an email claim. |
The dashboard exposes organizations, projects, overview analytics, agents and key rotation, API keys, origins, and seven-day event logs. Platform administration is available only at /admin when the session reports system_admin: true.
Stable error codes
Error handling
{
"error": {
"code": "challenge_expired",
"message": "Challenge has expired."
},
"request_id": "req_..."
}Branch on error.code, not the message. Every response includes X-Request-ID.
| Code | HTTP | Meaning |
|---|---|---|
invalid_site_key | 401 | Public site key is unknown, revoked, or outside an active project. |
insufficient_api_scope | 403 | API key does not grant the requested operation. |
origin_required | 400 | A valid browser Origin header was not supplied. |
origin_mismatch | 403 | Origin is not registered for this project. |
agent_permission_denied | 403 | Action, audience, resource, or scopes exceed agent policy. |
challenge_not_found | 404 | Challenge ID is unknown. |
challenge_expired | 410 | The signing window elapsed. |
challenge_replayed | 409 | The challenge was already completed. |
invalid_signature | 422 | Ed25519 signature format or verification failed. |
attempts_exhausted | 429 | Five proof attempts were used. |
replayed | 409 | Token was already consumed. |
insufficient_role | 403 | Organization role cannot perform the management mutation. |
system_admin_required | 403 | Platform-admin endpoint requires an explicitly operator-promoted account. |
rate_limited | 429 | Request limit was exceeded. |
Abuse controls
Rate limits
| Operation | Default limit |
|---|---|
| Create challenge | 30/minute/IP and 300/minute/project |
| Submit agent proof | 60/minute/IP, 600/minute/project, 5/challenge |
| Verify or introspect token | 60/minute/IP and 600/minute/API key |
| Login or registration | 10 attempts/15 minutes/IP |
| Project security management | 20/hour/project |
| Access application | 3/day/IP |
Responses expose RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset. A 429 may include Retry-After.
Trust precisely
Security model
AF Verify proves that an active key registered to an active agent signed a fresh challenge within its explicit policy. The resulting token is signed by AF Verify and bound to the business operation.
What is bound
- Issuer, organization, project, public site key, and exact browser origin.
- Action, audience, resource, scopes, and caller-supplied context hash.
- Challenge nonce, issue time, expiry, agent ID, and agent key ID.
- Token issuer, audience, authorized party, subject, JWT ID, and assurance method.
What AF Verify does not establish
- That no human initiated, supervised, or controlled the agent.
- That the agent is safe, correct, autonomous, or using a particular model.
- That a valid credential has not been stolen or deliberately shared.
- That the requested action satisfies your application’s authorization rules.
Current availability
SDK status
The REST API, widget, OpenAPI description, Ed25519 examples, and JWKS endpoint are available now. Tested JavaScript/TypeScript and Python reference SDK source is included with the project, but neither package has been published to a public registry.
| Integration | Status |
|---|---|
| Browser widget | Available |
| REST API + OpenAPI | Available |
| JavaScript / TypeScript SDK | Reference source included · not published |
| Python SDK | Reference source included · not published |
| Go SDK | Planned |
| Agent signer toolkit | Coming soon |
