Skip to main content

Authentication

The gateway has no passwords. You prove you control a wallet by signing a one-time message, and the gateway returns a JWT.

The private key never leaves the client.

Get a challenge​

GET /auth/challenge · Public

The message is an EIP-4361 (Sign-In with Ethereum) message. Sign it exactly as returned. A new challenge for the same address replaces the previous one.

QueryRequiredDescription
addressYesThe wallet signing in, 0x followed by 40 hex characters.
chainIdYesThe chain of the connected wallet, for example 137 (Polygon) or 80002 (Amoy).

Response 200:

{
"message": "orchestrator.example.com wants you to sign in with your Ethereum account:\n0xAbC...\n\nSign in to the 6022 orchestrator.\n\nURI: https://orchestrator.example.com\nVersion: 1\nChain ID: 137\nNonce: ...\nIssued At: 2026-09-30T10:00:00Z\nExpiration Time: 2026-09-30T10:05:00Z",
"expiresAt": "2026-09-30T10:05:00Z"
}
StatusWhen
400The address is malformed.

Exchange the signature for a token​

POST /auth/verify · Public

The challenge can be used once.

{
"address": "0xAbC...",
"signature": "0x..."
}

signature is the personal_sign (EIP-191) signature of the message: 0x followed by 65 bytes in hex.

Response 200:

{
"token": "eyJhbGciOiJIUzI1NiIs...",
"expiresAt": "2026-09-30T11:00:00Z"
}
StatusWhen
400The address or signature is malformed.
401The challenge expired or was already used (challenge_not_found), or the signature doesn't match the address (signature_mismatch).

Use the token​

Send it on every call that needs a session:

curl https://orchestrator.example.com/conversations \
-H "Authorization: Bearer <token>"

A missing, invalid or expired token gets 401. There's no refresh endpoint: sign in again.

Examples​

Python, with eth-account and requests:

import requests
from eth_account import Account
from eth_account.messages import encode_defunct

BASE_URL = "https://orchestrator.example.com"
wallet = Account.from_key("0x<private key>")

challenge = requests.get(
f"{BASE_URL}/auth/challenge",
params={"address": wallet.address, "chainId": 137},
).json()

signed = wallet.sign_message(encode_defunct(text=challenge["message"]))

token = requests.post(
f"{BASE_URL}/auth/verify",
json={"address": wallet.address, "signature": "0x" + signed.signature.hex().removeprefix("0x")},
).json()["token"]

JavaScript, with ethers v6:

import { Wallet } from "ethers";

const BASE_URL = "https://orchestrator.example.com";
const wallet = new Wallet(process.env.PRIVATE_KEY);

const params = new URLSearchParams({ address: wallet.address, chainId: "137" });
const { message } = await (await fetch(`${BASE_URL}/auth/challenge?${params}`)).json();

const signature = await wallet.signMessage(message);

const { token } = await (
await fetch(`${BASE_URL}/auth/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address: wallet.address, signature }),
})
).json();
warning

Use a dedicated wallet for scripts. Never put the key of a wallet that owns valuable agents in a script, a CI variable or a shared .env file.

What the token contains​

The token is an HS256 JWT signed with JWT__HASH. Its claims:

ClaimMeaning
subThe wallet address that signed in.
iatIssued at.
nbfNot before.
expExpiry, JWT__LIVING_TIME seconds after issue.

The token isn't tied to a chain. The chainId you pass only goes into the signed message.

Ownership​

A token proves you control a wallet, not an agent. Some endpoints also check that the wallet owns the agent or is the agent's wallet.

CheckEndpointsOtherwise
The wallet in sub holds the agent's NFT or is the agent's walletPrompt templates403, code agent_access_forbidden
The same, for one agent in the conversationA single conversation and its sub-routes403, code conversation_access_forbidden

Ownership follows the NFT's on-chain transfers, after a short delay for block finality. Nobody has access to an agent that isn't minted yet. If the registry can't be reached, you get 502.

Signed requests​

Instead of a token, a caller can sign each request with its wallet. Send three headers:

HeaderValue
X-Agentic-Orchestrator-TimestampUnix time in seconds. Rejected if more than 60 seconds away from the gateway's clock (SIWE__SIGNED_REQUEST_MAX_AGE_SECONDS).
X-Agentic-Orchestrator-Nonce16 to 128 random letters, digits, - or _. Each nonce works once per wallet.
X-Agentic-Orchestrator-SignatureThe EIP-191 (personal_sign) signature of the message below, 0x followed by 65 bytes in hex.

The signed message is these eight lines, joined with \n:

6022 orchestrator signed request
<gateway domain, the host of SIWE__URI>
<METHOD>
<path, without the query, as the gateway receives it>
<raw query string, or an empty line>
<timestamp>
<nonce>
<Keccak-256 of the raw body, 0x-prefixed lowercase hex>
import { Wallet, hexlify, keccak256, randomBytes, toUtf8Bytes } from "ethers"

const wallet = new Wallet(process.env.PRIVATE_KEY!)
const body = JSON.stringify({ content: "You are {{.Agent.Name}}..." })
const timestamp = Math.floor(Date.now() / 1000).toString()
const nonce = hexlify(randomBytes(16)).slice(2)
const message = [
"6022 orchestrator signed request",
"orchestrator.example.com",
"POST",
"/agent/0x1234.../3/prompt-template/expert-system",
"",
timestamp,
nonce,
keccak256(toUtf8Bytes(body)),
].join("\n")
const signature = await wallet.signMessage(message)

Behind a reverse proxy that rewrites paths, sign the path the gateway sees, not the public one. A request with a wrong, expired or reused signature is treated as unauthenticated.

Gateway settings​

VariableDefaultDescription
JWT__HASHNoneHMAC secret for the JWTs. At least 32 characters. Required.
JWT__LIVING_TIMENoneToken lifetime in seconds. Required.
JWT__NONCE_TTL300How long a challenge stays valid, in seconds.
SIWE__URIhttp://localhost:5173The URI in the sign-in message. Its host becomes the message's domain, which wallets show. Set it to your console's public URL.
SIWE__STATEMENTSign in to the 6022 orchestrator.The statement line of the message.
SIWE__SIGNED_REQUEST_MAX_AGE_SECONDS60How far a signed request's timestamp may be from the gateway's clock, in seconds.