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:
- Gateway —
POST /a2a/<agent_id>and the SSE stream endpoint route requests to agents. A verified token identifies the caller; that identity becomes the agent'sX-KubeMQ-Caller-ID. - Registry —
POST /agents/register,/agents/heartbeat,/agents/deregister, andDELETE /agents/<agent_id>are owned operations. The principal in your token becomes the agent'sregistered_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'sregistered_byis set automatically from the token's principal — clients cannot spoof it in the body. heartbeat,deregister, andDELETE /agents/<id>succeed only when the caller's principal matches the agent'sregistered_by. A mismatch returns 403 Forbidden (ownership conflict).- If an agent's
registered_byis 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.
| Operation | Auth | Ownership check |
|---|---|---|
GET /.well-known/agent-card.json | Public | — |
GET /agents, GET /agents/<id> | Bearer (when auth enabled) | None (read-only) |
POST /agents/register | Bearer | Sets registered_by; rejects cross-principal re-register |
POST /agents/heartbeat | Bearer | Caller must own the agent |
POST /agents/deregister, DELETE /agents/<id> | Bearer | Caller must own the agent |
POST /a2a/<id> (gateway) | Bearer | None (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.
| Header | Set by | Visible to agent | Contains |
|---|---|---|---|
Authorization: Bearer | Caller | No (stripped) | The caller's JWT |
X-KubeMQ-Caller-ID | Virtual subscriber | Yes | The 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.
Related
Auth & Security
The shared JWT, CORS, origin-validation, and TLS model behind every connector.
Agent registry
Register, list, heartbeat, and deregister agents — where ownership is enforced.
Synchronous messaging
message/send and header forwarding through the gateway.
Building agents
Build a plain HTTP agent and trust X-KubeMQ-Caller-ID for caller identity.
Was this page helpful?
Building Agents
Build an A2A-compliant HTTP agent for KubeMQ — a plain JSON-RPC 2.0 server with no KubeMQ SDK, registered by URL via the registry.
Concurrency & Limits
Per-agent concurrency caps, the 10MB response-size limit, and timeout capping that protect the A2A gateway from overload and runaway agents.