When AUTH__ENABLED=true, every call to a Bindu agent must do two things:
- Prove you're allowed — attach a short-lived bearer token from Hydra (the OAuth server).
- Prove you're really you — sign the request body with your DID's private key and attach the signature.
Either one missing → the agent rejects with -32009. Get both right → the request goes through.
That's the whole model. The rest of this doc shows you exactly what to put on the wire.
Want the long-form explanation? AUTHENTICATION.md covers the bearer-token half in depth; DID.md covers the signing half. This page is the shortest path to a working request.
Every request to an auth-on Bindu agent carries these four headers:
Authorization: Bearer <access_token> ← from Hydra, expires in ~1h
X-DID: did:bindu:<author>:<name>:<id> ← your identity
X-DID-Timestamp: <unix-seconds> ← within 300s of server clock
X-DID-Signature: <base58 Ed25519 sig> ← signs {body, did, timestamp}
The agent verifies them in four gates. The first failure stops the chain. Observed responses against a live AUTH__ENABLED=true agent:
| Gate | What's checked | On failure |
|---|---|---|
| 1 | Bearer token present and active in Hydra | HTTP 401 + JSON-RPC -32009 "Authentication is required..." |
| 2 | X-DID matches the token's client_id |
HTTP 403 + {"error":"Invalid DID signature","details":{"reason":"did_mismatch"}} |
| 3 | Public key for that DID is registered in Hydra client metadata | HTTP 403 + details.reason = public_key_unavailable |
| 4 | Timestamp within 300s and signature verifies | HTTP 403 + details.reason = invalid_signature (covers both clock skew and bad sig — middleware collapses them) |
If all four pass, your handler runs.
Most people shouldn't hand-roll this. Three callers in the repo do the whole chain for you:
| Caller | What you provide | What it does |
|---|---|---|
Inbox (POST /api/compose) |
Persona + OpenRouter key (via UI) | Spawns your personal agent, registers it with Hydra, signs every outbound message. See inbox/README.md. |
Gateway (POST /plan) |
BINDU_GATEWAY_DID_SEED + Hydra URLs |
Same identity for every peer call. See GATEWAY.md. |
| Postman collection | Seed + DID + secret in environment | Pre-request script signs each call. See docs/postman-did-signing.js. |
If you're testing against an auth-on agent right now, the fastest verification is:
# In the inbox, with poet_agent running on 5776 with AUTH on:
curl -s -X POST http://127.0.0.1:3787/api/ecosystem \
-H 'content-type: application/json' \
-d '{"id":"poet_agent","url":"http://127.0.0.1:5776"}'
curl -s -X POST http://127.0.0.1:3787/api/compose \
-H 'content-type: application/json' \
-d '{"agentId":"poet_agent","text":"write a 4-line poem"}'
# → {"ok":true,"status":200,"contextId":"...","taskId":"...","response":{...}}If ok:true, status:200 comes back, every gate above passed. You're done.
The rest of this doc is for people writing the caller from scratch in a new language.
You need three durable artifacts: a seed (your secret), a DID (your public name), and an OAuth client registered in Hydra that ties them together.
uv run python -c "
import hashlib, os, base64, base58
from nacl.signing import SigningKey
AUTHOR = 'you_at_example_com' # your email, @ → _at_, . → _
NAME = 'my_agent' # short label, no colons
seed = os.urandom(32)
pk = bytes(SigningKey(seed).verify_key)
sha = hashlib.sha256(pk).hexdigest()
agent_id = f'{sha[0:8]}-{sha[8:12]}-{sha[12:16]}-{sha[16:20]}-{sha[20:32]}'
did = f'did:bindu:{AUTHOR}:{NAME}:{agent_id}'
print('SEED_B64 =', base64.b64encode(seed).decode())
print('DID =', did)
print('PUBLIC_KEY_B58 =', base58.b58encode(pk).decode())
"Save all three. The seed is your private key — losing it orphans the DID, leaking it lets anyone impersonate you.
Now register the client in Hydra. The DID is the client_id:
CLIENT_SECRET=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_')
curl -X POST https://hydra-admin.getbindu.com/admin/clients \
-H 'Content-Type: application/json' \
-d '{
"client_id": "'"$DID"'",
"client_secret": "'"$CLIENT_SECRET"'",
"grant_types": ["client_credentials"],
"response_types":["token"],
"scope": "openid offline agent:read agent:write",
"token_endpoint_auth_method": "client_secret_post",
"metadata": {
"did": "'"$DID"'",
"public_key": "'"$PUBLIC_KEY_B58"'",
"key_type": "Ed25519",
"verification_method": "Ed25519VerificationKey2020",
"hybrid_auth": true
}
}'The critical field is metadata.public_key — that's how the agent finds your public key at Gate 3. Save the client_secret; you need it to mint tokens.
Four steps. The first one runs ~once an hour (token cache); the other three run every call.
curl -s -X POST https://hydra.getbindu.com/oauth2/token \
-d grant_type=client_credentials \
-d "client_id=$DID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=agent:read agent:write"Response:
{ "access_token": "ory_at_...", "expires_in": 3599, "scope": "agent:read agent:write", "token_type": "bearer" }Cache the token in memory; refresh ~60s before expires_in runs out.
Serialize it once and keep the exact bytes. The bytes you sign must equal the bytes you send.
body_bytes = json.dumps({
"jsonrpc": "2.0",
"id": "<uuid>",
"method": "message/send",
"params": {
"message": {
"role": "user", "kind": "message",
"parts": [{"kind": "text", "text": "your prompt"}],
"messageId": "<uuid>", "contextId": "<uuid>", "taskId": "<uuid>",
},
# Required. Drop it and the agent's request validator 400s
# before the auth middleware even sees the request.
"configuration": {"acceptedOutputModes": ["application/json"]},
}
}).encode("utf-8")The signing payload is a second JSON object that wraps the body as a string:
ts = int(time.time())
signing_str = json.dumps(
{"body": body_bytes.decode("utf-8"), "did": did, "timestamp": ts},
sort_keys=True, # ← required
)
# default Python separators: ", " and ": " — note the spaces.
sig_b58 = base58.b58encode(SigningKey(seed).sign(signing_str.encode("utf-8")).signature).decode()
⚠️ The #1 cross-language gotcha. JavaScript'sJSON.stringifyomits spaces after:and,; Python'sjson.dumpsincludes them. The signing payload above uses Python's defaults. If you sign one shape and the server reconstructs the other, the signature won't verify and you'll see HTTP 403 +details.reason: invalid_signature. Use the canonical fixture to verify your implementation in any language.
requests.post(
f"{agent_url}/",
data=body_bytes, # ← exactly the bytes you signed
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
"X-DID": did,
"X-DID-Timestamp": str(ts),
"X-DID-Signature": sig_b58,
},
)If the agent's middleware passes all four gates, your handler runs and you get a normal task response back.
| Response | Most likely cause | Fix |
|---|---|---|
HTTP 401, JSON-RPC -32009 Authentication is required |
No Authorization header, or token is invalid/expired |
Mint a fresh token, attach as Authorization: Bearer … |
HTTP 403, details.reason = did_mismatch |
X-DID doesn't match the token's client_id |
Mint the token with the same DID you send as X-DID |
HTTP 403, details.reason = public_key_unavailable |
metadata.public_key missing on the Hydra client, or you registered against a different Hydra |
GET /admin/clients/<did> and check |
HTTP 403, details.reason = invalid_signature |
One of: clock skew > 300s, replayed timestamp, body bytes drifted between sign and send, sort_keys/whitespace mismatch, signed with the wrong seed | Sign fresh on every request; sign the exact bytes you'll send; verify against the canonical fixture |
HTTP 400, -32700 JSON parse error (e.g. params.configuration field required) |
Body shape wrong before auth runs — JSON-RPC validator rejects upfront | This is a body bug, not an auth bug. Include params.configuration and confirm against an unauthed peer first |
invalid_client from /oauth2/token |
Wrong client_secret or client not registered on this Hydra |
GET /admin/clients/<did> to confirm |
invalid_scope from /oauth2/token |
Requesting a scope the client wasn't registered with | Re-register with the scope, or drop it |
The middleware collapses the four sub-causes of "signature didn't verify" into one invalid_signature reason. To narrow it down: re-sign with a fresh timestamp first — that eliminates clock skew and replay. If it still fails, you have a body-byte or key-mismatch issue.
A debugging shortcut: introspect your own token and check client_id is what you expect.
curl -s -X POST https://hydra-admin.getbindu.com/admin/oauth2/introspect \
-d "token=$ACCESS_TOKEN" \
| python3 -m json.toolLook for active: true, client_id == your DID, and exp > now. If any of these is off, that's your bug.
Use this to verify your sign-and-encode implementation matches every other Bindu caller.
| Input | Value |
|---|---|
| Seed | 32 zero bytes |
| DID | did:bindu:test |
| Body | {"test": "value"} |
| Timestamp | 1000 |
Signing payload (note spaces after : and ,):
{"body": "{\"test\": \"value\"}", "did": "did:bindu:test", "timestamp": 1000}
Expected base58 signature:
3SfU4VPTHLbzZzCn17ZqU6y2tnzHQbdo2nnXQr6XZXk34XgyzwSKRrCYEWRmmGXrV39mdkyhTsy5oasfTpNuqyM2
Your code matches → ship it. Doesn't match → you're missing the spaces, your keys aren't sorted, or your base58 alphabet is wrong (Bindu uses Bitcoin alphabet — nacl-base58 uses the same).
- AUTHENTICATION.md — full teaching-voice walkthrough of the bearer-token side
- DID.md — full teaching-voice walkthrough of the signing side
gateway/src/bindu/identity/local.ts— reference TypeScript implementationinbox/server/index.ts§outbound A2A auth + §DID signature — same flow, plain Nodedocs/postman-did-signing.js— Postman pre-request script