KubeMQ
AiwayAI Agents (A2A)Guides

Authentication

Secure A2A gateway and registry calls with JWT Bearer tokens — agent ownership, caller identity, and why the agent never sees your token.

The A2A connector is guarded by the same JWT Bearer authentication as every other KubeMQ connector. You authenticate to the gateway (/a2a/*) and the registry (/agents/*) with an Authorization: Bearer header; KubeMQ verifies the token, records who you are, and propagates your identity to the agent as X-KubeMQ-Caller-ID — without ever forwarding your token downstream.

Overview

Authentication for A2A is the connector-wide model described in Auth & Security, applied to two route families:

  • GatewayPOST /a2a/<agent_id> and the SSE stream endpoint route requests to agents. A verified token identifies the caller; that identity becomes the agent's X-KubeMQ-Caller-ID.
  • RegistryPOST /agents/register, /agents/heartbeat, /agents/deregister, and DELETE /agents/<agent_id> are owned operations. The principal in your token becomes the agent's registered_by, and only that principal may later modify or delete it.

When server authentication is disabled, every caller is treated as the synthetic anonymous principal and no token is required. When it is enabled, an unverified or missing token is rejected — as a JSON-RPC -32010 error on /a2a/*, or HTTP 401 on the REST registry endpoints.

A2A discovery is intentionally public: GET /.well-known/agent-card.json and any per-agent /.well-known/agent-card.json path bypass authentication so a caller can read an agent card before it has a token.

How it works

The token is verified once, at the shared auth middleware, before the request reaches the gateway. The middleware attaches your claims (including ClientID) to the request; the gateway uses that identity for registry ownership and stamps it onto the agent call as X-KubeMQ-Caller-ID. The agent receives the caller's identity — never the raw token.

KubeMQ verifies the token at the edge and forwards the caller's identity — not the token — to the agent.

The Authorization header is stripped before the gateway calls the agent — along with Cookie, Proxy-Authorization, and other sensitive or hop-by-hop headers. Agents must trust X-KubeMQ-Caller-ID for the caller's identity, not a forwarded token. If an agent needs its own credential, register it with the agent's URL and let the agent authenticate downstream itself.

Authenticating gateway calls

Add an Authorization: Bearer <jwt> header to any POST /a2a/<agent_id> request. The header carrying the token is consumed by KubeMQ; the rest of the request — including any X-* headers — is forwarded to the agent. These snippets send a message/send with a Bearer token; on success the agent echoes its received_headers and you can see that Authorization is absent and X-KubeMQ-Caller-ID is present.

curl -X POST http://localhost:9090/a2a/echo-agent-01 \
  -H "Authorization: Bearer $KUBEMQ_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "message/send",
    "params": {
      "message": {"parts": [{"text": "Authenticated call"}]}
    }
  }'
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;

const string KubeMqUrl = "http://localhost:9090";
const string AgentId = "echo-agent-01";
var token = Environment.GetEnvironmentVariable("KUBEMQ_TOKEN");

var payload = new JsonObject
{
    ["jsonrpc"] = "2.0",
    ["id"] = 1,
    ["method"] = "message/send",
    ["params"] = new JsonObject
    {
        ["message"] = new JsonObject
        {
            ["parts"] = new JsonArray(new JsonObject { ["text"] = "Authenticated call" })
        }
    }
};

using var client = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, $"{KubeMqUrl}/a2a/{AgentId}")
{
    Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")
};
request.Headers.Add("Authorization", $"Bearer {token}");

var resp = await client.SendAsync(request);
var body = await resp.Content.ReadAsStringAsync();
var data = JsonNode.Parse(body)!;

var received = data["result"]?["received_headers"];
Console.WriteLine($"Status: {(int)resp.StatusCode}");
Console.WriteLine($"Caller ID seen by agent: {received?["X-KubeMQ-Caller-ID"]?.GetValue<string>()}");
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
)

const (
	kubemqURL = "http://localhost:9090"
	agentID   = "echo-agent-01"
)

func main() {
	token := os.Getenv("KUBEMQ_TOKEN")
	payload := map[string]interface{}{
		"jsonrpc": "2.0",
		"id":      1,
		"method":  "message/send",
		"params": map[string]interface{}{
			"message": map[string]interface{}{
				"parts": []map[string]interface{}{{"text": "Authenticated call"}},
			},
		},
	}

	data, _ := json.Marshal(payload)
	req, _ := http.NewRequest("POST", kubemqURL+"/a2a/"+agentID, bytes.NewReader(data))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+token)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
		os.Exit(1)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	var result map[string]interface{}
	json.Unmarshal(body, &result)
	received, _ := result["result"].(map[string]interface{})["received_headers"].(map[string]interface{})
	fmt.Printf("Status: %d\n", resp.StatusCode)
	fmt.Printf("Caller ID seen by agent: %v\n", received["X-KubeMQ-Caller-ID"])
}
import com.fasterxml.jackson.databind.ObjectMapper;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;

public class Client {

    static final String KUBEMQ_URL = "http://localhost:9090";
    static final String AGENT_ID = "echo-agent-01";
    static final ObjectMapper MAPPER = new ObjectMapper();

    public static void main(String[] args) throws Exception {
        var token = System.getenv("KUBEMQ_TOKEN");
        var payload = Map.of(
            "jsonrpc", "2.0",
            "id", 1,
            "method", "message/send",
            "params", Map.of(
                "message", Map.of("parts", List.of(Map.of("text", "Authenticated call")))
            )
        );

        var client = HttpClient.newHttpClient();
        var req = HttpRequest.newBuilder()
            .uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID))
            .header("Content-Type", "application/json")
            .header("Authorization", "Bearer " + token)
            .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
            .build();
        var resp = client.send(req, HttpResponse.BodyHandlers.ofString());

        var data = MAPPER.readTree(resp.body());
        var received = data.path("result").path("received_headers");
        System.out.println("Status: " + resp.statusCode());
        System.out.println("Caller ID seen by agent: " + received.path("X-KubeMQ-Caller-ID").asText());
    }
}
import json
import os

import httpx

KUBEMQ_URL = "http://localhost:9090"
AGENT_ID = "echo-agent-01"

payload = {
    "jsonrpc": "2.0",
    "id": 1,
    "method": "message/send",
    "params": {
        "message": {"parts": [{"text": "Authenticated call"}]},
    },
}

token = os.environ["KUBEMQ_TOKEN"]
async with httpx.AsyncClient() as client:
    resp = await client.post(
        f"{KUBEMQ_URL}/a2a/{AGENT_ID}",
        json=payload,
        headers={"Authorization": f"Bearer {token}"},
    )
    data = resp.json()
    received = data.get("result", {}).get("received_headers", {})
    print(f"Status: {resp.status_code}")
    print(f"Caller ID seen by agent: {received.get('X-KubeMQ-Caller-ID')}")
const KUBEMQ_URL = "http://localhost:9090";
const AGENT_ID = "echo-agent-01";
const token = process.env.KUBEMQ_TOKEN;

const request = {
  jsonrpc: "2.0",
  id: 1,
  method: "message/send",
  params: {
    message: { parts: [{ text: "Authenticated call" }] },
  },
};

const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify(request),
});

const data = await resp.json();
const received = data.result?.received_headers || {};
console.log(`Status: ${resp.status}`);
console.log(`Caller ID seen by agent: ${received["x-kubemq-caller-id"]}`);

Authenticating registry calls

The registry uses the same Authorization: Bearer header. The difference is what KubeMQ does with your identity: POST /agents/register records the token's principal as the agent's registered_by, and ownership-protected operations (heartbeat, deregister, DELETE) verify that the caller's principal matches.

curl -X POST http://localhost:9090/agents/register \
  -H "Authorization: Bearer $KUBEMQ_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "echo-agent-01",
    "name": "Echo Agent",
    "url": "http://echo-agent.internal:8000/",
    "skill_tags": ["echo"]
  }'
const string KubeMqUrl = "http://localhost:9090";
var token = Environment.GetEnvironmentVariable("KUBEMQ_TOKEN");

var card = new JsonObject
{
    ["agent_id"] = "echo-agent-01",
    ["name"] = "Echo Agent",
    ["url"] = "http://echo-agent.internal:8000/",
    ["skill_tags"] = new JsonArray("echo")
};

using var client = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, $"{KubeMqUrl}/agents/register")
{
    Content = new StringContent(card.ToJsonString(), Encoding.UTF8, "application/json")
};
request.Headers.Add("Authorization", $"Bearer {token}");

var resp = await client.SendAsync(request);
var data = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!;
Console.WriteLine($"registered_by: {data["registered_by"]?.GetValue<string>()}");
token := os.Getenv("KUBEMQ_TOKEN")
card := map[string]interface{}{
	"agent_id":   "echo-agent-01",
	"name":       "Echo Agent",
	"url":        "http://echo-agent.internal:8000/",
	"skill_tags": []string{"echo"},
}

data, _ := json.Marshal(card)
req, _ := http.NewRequest("POST", "http://localhost:9090/agents/register", bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)

resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
json.Unmarshal(body, &result)
fmt.Printf("registered_by: %v\n", result["registered_by"])
var token = System.getenv("KUBEMQ_TOKEN");
var card = Map.of(
    "agent_id", "echo-agent-01",
    "name", "Echo Agent",
    "url", "http://echo-agent.internal:8000/",
    "skill_tags", List.of("echo")
);

var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
    .uri(URI.create(KUBEMQ_URL + "/agents/register"))
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer " + token)
    .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(card)))
    .build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());

var data = MAPPER.readTree(resp.body());
System.out.println("registered_by: " + data.path("registered_by").asText());
token = os.environ["KUBEMQ_TOKEN"]
card = {
    "agent_id": "echo-agent-01",
    "name": "Echo Agent",
    "url": "http://echo-agent.internal:8000/",
    "skill_tags": ["echo"],
}

async with httpx.AsyncClient() as client:
    resp = await client.post(
        "http://localhost:9090/agents/register",
        json=card,
        headers={"Authorization": f"Bearer {token}"},
    )
    data = resp.json()
    print(f"registered_by: {data.get('registered_by')}")
const token = process.env.KUBEMQ_TOKEN;
const card = {
  agent_id: "echo-agent-01",
  name: "Echo Agent",
  url: "http://echo-agent.internal:8000/",
  skill_tags: ["echo"],
};

const resp = await fetch(`${KUBEMQ_URL}/agents/register`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify(card),
});

const data = await resp.json();
console.log(`registered_by: ${data.registered_by}`);

Agent ownership

When authentication is enabled, the registry enforces ownership so one principal cannot hijack or delete another principal's agents.

  • On POST /agents/register, the agent's registered_by is set automatically from the token's principal — clients cannot spoof it in the body.
  • heartbeat, deregister, and DELETE /agents/<id> succeed only when the caller's principal matches the agent's registered_by. A mismatch returns 403 Forbidden (ownership conflict).
  • If an agent's registered_by is blank while auth is enabled, the ownership check fails closed — no principal can modify or delete it. Re-register the agent with a token to take ownership.
OperationAuthOwnership check
GET /.well-known/agent-card.jsonPublic
GET /agents, GET /agents/<id>Bearer (when auth enabled)None (read-only)
POST /agents/registerBearerSets registered_by; rejects cross-principal re-register
POST /agents/heartbeatBearerCaller must own the agent
POST /agents/deregister, DELETE /agents/<id>BearerCaller must own the agent
POST /a2a/<id> (gateway)BearerNone (any authenticated caller may message any agent)

Caller identity reaches the agent

Because the gateway strips Authorization before calling the agent, the agent learns who is calling from the X-KubeMQ-Caller-ID header, which the virtual subscriber always injects with the caller's ClientID. This holds regardless of which transport originated the call (A2A HTTP, gRPC, REST, or the MCP bridge), so an agent can apply its own per-caller logic without parsing tokens.

HeaderSet byVisible to agentContains
Authorization: BearerCallerNo (stripped)The caller's JWT
X-KubeMQ-Caller-IDVirtual subscriberYesThe caller's ClientID (identity)

When server authentication is disabled, the injected X-KubeMQ-Caller-ID reflects the synthetic anonymous identity — agents that gate on caller identity should account for this in non-secured deployments.

Was this page helpful?

On this page