---
title: Sign your own assertion
description: The gateway wire protocol in full. Everything you need to authenticate an agent from any language that can sign Ed25519.
label: Sign your own assertion
---

There is no SDK for your language, or you want to see the protocol with nothing
in the way. Both are reasonable, and the gateway cannot tell the difference —
which is exactly the property you want from a security boundary.

## The assertion

A compact JWS, EdDSA over Ed25519:

```text
base64url(header) . base64url(canonicalJson(payload)) . base64url(signature)
```

**Header**

```json
{ "typ": "veefore-assertion+jwt", "alg": "EdDSA", "kid": "key_8f2c1d4e5a6b7c8d" }
```

**Payload**

```json
{
  "sub": "agt_01JAV7YQ4T2K9M3E8H6R5N1XZ0",
  "aud": "https://gateway.veefore.example.com",
  "purpose": "agent_authentication",
  "nonce": "YmFzZTY0dXJsLXJhbmRvbQ",
  "iat": "2026-10-03T12:38:04.000Z",
  "exp": "2026-10-03T12:40:04.000Z"
}
```

:::params

:::param sub | string | required
The agent id.
:::

:::param aud | string | required
The gateway audience, compared as a **string**. Fetch it; do not assume it.
:::

:::param purpose | string | required
`agent_authentication` for `/token`, `handshake` for `/handshake`. They are not
interchangeable — an assertion minted for one is refused for the other.
:::

:::param nonce | string | required
Single use. 18 random bytes, base64url, is what the SDKs generate. Reuse is
`NONCE_REPLAYED`.
:::

:::param iat | ISO-8601 | required
Issued at, UTC.
:::

:::param exp | ISO-8601 | required
Expiry. The SDKs use `iat + 120s`. Keep it short; a long-lived assertion is a
bearer credential.
:::

:::

`iat` and `exp` are **ISO-8601 strings**, not numeric epoch seconds. This
departs from common JWT practice deliberately — every other timestamp in the
Veefore API is ISO-8601, and a single format across the whole surface is worth
more than conformance to a convention here.

## Canonical JSON

The payload is canonicalised before signing. This must match byte for byte or
the signature fails.

Rules, RFC 8785-style:

- Object keys sorted by Unicode code point, ascending
- `undefined` values omitted entirely
- No insignificant whitespace
- Non-finite numbers become `null`
- `Date` values become their ISO-8601 string

```text
{"aud": "https://gateway.veefore.example.com","exp":"2026-10-03T12:40:04.000Z","iat":"2026-10-03T12:38:04.000Z","nonce":"YmFzZTY0dXJsLXJhbmRvbQ","purpose":"agent_authentication","sub":"agt_01JAV7YQ4T2K9M3E8H6R5N1XZ0"}
```

The **header** is `JSON.stringify`'d, not canonicalised — its three keys are
already in a fixed order. Only the payload is canonicalised.

:::warning Ed25519 signs the message, not a digest
Ed25519 is a one-shot scheme. Sign the raw UTF-8 bytes of
`base64url(header) + "." + base64url(canonicalPayload)`. Do not pre-hash with
SHA-256 and sign the digest — that produces a valid-looking signature over the
wrong message, and it is the most common mistake in a from-scratch
implementation.
:::

## Discover the audience first

```bash
curl -s https://gateway.veefore.example.com/.well-known/veefore-platform-key
```

```json
{
  "key_id": "key_426daf15e177da4a",
  "algorithm": "Ed25519",
  "public_key": "MCowBQYDK2VwAyEA…",
  "format": "spki-base64",
  "audience": "https://gateway.veefore.example.com"
}
```

Unauthenticated, cached for five minutes. Use `audience` verbatim.

The URL you dial and the gateway's configured public URL are frequently
different text — `127.0.0.1` against `localhost`, an internal service name
behind a load balancer, a port-forward in development. The check compares
strings, not hosts, and a mismatch is a bare `401`.

Publishing the audience is safe: it is the value you must sign *for*, and
knowing it grants nothing without the private key.

## Get a session token

```http
POST /gateway/v1/token
content-type: application/json

{
  "agent_id": "agt_01JAV7YQ4T2K9M3E8H6R5N1XZ0",
  "assertion": "eyJ0eXAiOiJhZ2VudG9zLWFzc2VydGlvbit…",
  "sdk": { "name": "acme-go-client", "version": "0.3.1", "runtime": "go1.23" }
}
```

```json
{
  "data": {
    "token": "aos_at_…",
    "token_type": "Bearer",
    "expires_in": 900,
    "expires_at": "2026-10-03T12:53:04.000Z",
    "agent_id": "agt_01JAV…",
    "environment_id": "env_01JAV…",
    "organization_id": "org_01JAV…"
  }
}
```

`201`. Reporting `sdk` is optional and worth doing — it appears in the session
record, and `veefore doctor` uses it to flag version drift.

## Handshake instead, if you want the tool list

```http
POST /gateway/v1/handshake
content-type: application/json

{
  "agent_id": "agt_01JAV7YQ4T2K9M3E8H6R5N1XZ0",
  "proof": "eyJ0eXAiOiJhZ2VudG9zLWFzc2VydGlvbit…",
  "nonce": "YmFzZTY0dXJsLXJhbmRvbQ",
  "timestamp": "2026-10-03T12:38:04.000Z",
  "sdk": { "name": "acme-go-client", "version": "0.3.1", "runtime": "go1.23" },
  "declared_tools": ["refund_customer", "order_lookup"]
}
```

Note the differences from `/token`: the field is `proof` rather than
`assertion`, `nonce` and `timestamp` are repeated at the top level, `sdk` is
required with all three fields, and the assertion's `purpose` must be
`handshake`.

```json
{
  "data": {
    "agent_id": "agt_01JAV…",
    "session_token": "aos_at_…",
    "expires_at": "2026-10-03T12:53:04.000Z",
    "mode": "ENFORCE",
    "environment": { "id": "env_01JAV…", "name": "Production", "type": "PRODUCTION" },
    "organization": { "id": "org_01JAV…", "name": "Acme Payments" },
    "authorized_tools": [
      { "key": "refund_customer", "name": "Refund a customer order",
        "capability": "refund.create", "status": "ACTIVE" }
    ],
    "capabilities": ["order.read", "refund.create"],
    "policy_summary": { "bound_policies": 3, "mode": "ENFORCE" },
    "passport_version": 4
  }
}
```

Handshake additionally requires the agent to be `ACTIVE`, not isolated, and its
environment not paused. `/token` does not check those — the action pipeline
does.

## Request an action

```http
POST /gateway/v1/actions
authorization: Bearer aos_at_…
content-type: application/json
idempotency-key: refund-ord_8812-attempt-1

{
  "tool": "refund_customer",
  "capability": "refund.create",
  "action": "create",
  "parameters": { "orderId": "ord_8812", "reason": "damaged on arrival" },
  "amount": 25000,
  "currency": "INR",
  "destination": "payments.internal.example.com",
  "purpose": "Customer reported damage; agent verified photo evidence."
}
```

The response is the `ActionDecision` documented in
[How it works](/how-it-works#what-a-decision-looks-like). Branch on `allowed`.

Related routes: `POST /gateway/v1/actions/retry` with `approval_id` to resume
after an approval, `POST /gateway/v1/mcp/call`, `POST /gateway/v1/a2a/invoke`.

## Session handling

| | |
| --- | --- |
| Lifetime | `AGENT_TOKEN_TTL_SECONDS`, 900 by default |
| Refresh | None. Sign a fresh assertion and get a new token |
| On rotation | Every session dies; old assertions get `IDENTITY_GENERATION_STALE` |
| On revocation | Every session dies; `AGENT_IDENTITY_REVOKED` |

There is no refresh token on purpose. A refresh token is a long-lived bearer
credential, and the agent already holds something better — the private key.
Re-sign.

Treat `401` as "mint a new token and retry once". Treat a second `401` as a real
identity problem and stop.

## Checklist for a new implementation

- [ ] `audience` fetched from `/.well-known/veefore-platform-key`, not hard-coded
- [ ] Payload canonicalised: keys sorted, no whitespace, `undefined` omitted
- [ ] Header `JSON.stringify`'d, not canonicalised
- [ ] Ed25519 over the raw signing-input bytes, not a digest
- [ ] Fresh random nonce every time
- [ ] `exp` short — two minutes is plenty
- [ ] Correct `purpose` for the endpoint
- [ ] `403` never retried
- [ ] Transport errors and `503` retried with backoff
- [ ] Private key never logged and never in a request body

## Verify against a reference

```bash
npx tsx scripts/smoke-sdk.ts
npx tsx scripts/smoke-python-sdk.ts
```

Both exercise signing and canonicalisation against a live gateway. If your
canonical bytes match what those produce for the same payload, your signatures
will verify.

```ts
import { canonicalJson } from '@veefore/sdk';
console.log(canonicalJson(payload));
```

## Related

:::cards

:::card Agent identity | /concepts/identity
What the key is and what the checks are.
:::

:::card Gateway API | /reference/api/gateway
The enforcement plane routes.
:::

:::card Conventions | /reference/conventions
Envelopes, pagination, idempotency, correlation.
:::

:::card Errors | /reference/errors
Every refusal code. Generated.
:::

:::
