Skip to documentation
AF VERIFY Developer documentation
Powered byMAYOFLUX OpenAPI Dashboard

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.

Base URL · https://af.mayoflux.tvToken format · AFV+JWTAssurance · registered-agent-key

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.

  1. Create a project. Registration creates a test project, default API key, and http://localhost:3000 origin.
  2. Register the agent public key. AF Verify accepts Ed25519 SPKI PEM. The private key never leaves the agent runtime.
  3. Embed the widget. Put only the public site key in browser markup.
  4. Let the agent sign. The agent fetches the challenge, enforces its own policy, and submits an Ed25519 signature.
  5. Redeem the token on your server. Call /v1/siteverify with the secret API key before authorizing the requested action.
Security boundaryAF Verify proves possession of a registered workload credential. It does not prove that a human never initiated, supervised, or controlled the request.

Credential types

Authentication

CredentialWhere it belongsPurpose
public_site_keyBrowser widgetIdentifies the project. Exact Origin allowlisting still applies.
secret_api_keyServer or secrets managerBearer credential for token verification. Displayed only once.
Agent Ed25519 private keyAgent runtime or hardware-backed vaultSigns AFV2 challenges. Never uploaded to AF Verify.
Dashboard session + CSRF tokenAF Verify dashboardOrganization and project management. Not an integration credential.

Public browser authentication

HTTP headers
Origin: https://app.example.com
X-AF-Site-Key: afv_pk_test_...

Server authentication

HTTP header
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

POST/api/v1/challenges

A browser must send a registered Origin and a public site key with the challenge:create scope.

curl
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.

201 response
{
  "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.

Canonical format
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>
Do not improvise canonicalizationDo not trim fields, pretty-print the scopes array, add carriage returns, append a newline, or sign the JSON response itself. A 64-byte signature is encoded as exactly 86 unpadded base64url characters.

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

agent.mjs · Node 20+
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

Python · requests + cryptography
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())
POST/api/v1/verifications

Successful 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

POST/v1/siteverify

This server-only endpoint consumes a valid token by default. Always send the expected business values instead of accepting token claims without comparison.

curl
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\"
  }"
Success response
{
  "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

POST/api/v1/tokens/introspect

The 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

HTML
<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.

Browser events
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);
});
Programmatic modal
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.

Content Security PolicyAllow 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.

Run the live widget integration test

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.

Node.js · jose
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');
Local validation is not atomic redemptionSignature validation alone cannot determine whether another server already consumed the token. Use /v1/siteverify or maintain a durable replay cache keyed by jti.

Current delivery model

Webhooks and polling

Webhooks are not exposed by the current API.The supported browser flow polls the challenge’s status_url with X-AF-Poll-Token once per second until verification or expiry.
GET/v1/challenges/:id/status
Poll request
curl "$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.

RoleProject access
ownerFull organization and project management.
adminOrganization membership and project management.
developerCreate projects and manage agents, keys, and origins.
analyst, billing, read_onlyRead project data; mutations remain server-denied.
system_adminSeparate 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 envelope
{
  "error": {
    "code": "challenge_expired",
    "message": "Challenge has expired."
  },
  "request_id": "req_..."
}

Branch on error.code, not the message. Every response includes X-Request-ID.

CodeHTTPMeaning
invalid_site_key401Public site key is unknown, revoked, or outside an active project.
insufficient_api_scope403API key does not grant the requested operation.
origin_required400A valid browser Origin header was not supplied.
origin_mismatch403Origin is not registered for this project.
agent_permission_denied403Action, audience, resource, or scopes exceed agent policy.
challenge_not_found404Challenge ID is unknown.
challenge_expired410The signing window elapsed.
challenge_replayed409The challenge was already completed.
invalid_signature422Ed25519 signature format or verification failed.
attempts_exhausted429Five proof attempts were used.
replayed409Token was already consumed.
insufficient_role403Organization role cannot perform the management mutation.
system_admin_required403Platform-admin endpoint requires an explicitly operator-promoted account.
rate_limited429Request limit was exceeded.

Abuse controls

Rate limits

OperationDefault limit
Create challenge30/minute/IP and 300/minute/project
Submit agent proof60/minute/IP, 600/minute/project, 5/challenge
Verify or introspect token60/minute/IP and 600/minute/API key
Login or registration10 attempts/15 minutes/IP
Project security management20/hour/project
Access application3/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.
Keep a human-access path where requiredDo not use AF Verify to block accessibility, emergency, recovery, legal, or safety-critical workflows that must remain available to people.

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.

IntegrationStatus
Browser widgetAvailable
REST API + OpenAPIAvailable
JavaScript / TypeScript SDKReference source included · not published
Python SDKReference source included · not published
Go SDKPlanned
Agent signer toolkitComing soon

Open developer dashboard