An A2A agent names Authdog on its public agent card, then admits message calls only after require_auth accepts the bearer. The client exchanges client_credentials out of band and sends the token on the HTTP request. The JSON-RPC body stays the message.
Sample: a2a/python in authdog/agents-samples. Python 3.10+. Package a2a-sdk[http-server]==1.2.3.
Two surfaces
| Surface | Auth |
|---|---|
GET /.well-known/agent-card.json |
Public. Names the token URL and scope agent:invoke. |
POST /a2a/ |
authdog.starlette require_auth before the executor runs. |
The card is how a client discovers the scheme. The middleware is the gate. Publishing the card does not protect SendMessage.
Declare the scheme
Build an OAuth 2 client-credentials scheme whose token URL is the environment token_endpoint. Attach it as security scheme authdog with scope agent:invoke.
flow = ClientCredentialsOAuthFlow(token_url=token_url)
flow.scopes["agent:invoke"] = "Call this agent"
scheme = SecurityScheme(
oauth2_security_scheme=OAuth2SecurityScheme(
description="Authdog agent identity (client credentials)",
flows=OAuthFlows(client_credentials=flow),
)
)
requirement = SecurityRequirement()
requirement.schemes["authdog"].list.append("agent:invoke")Pass security_schemes={"authdog": scheme} and security_requirements=[requirement] on the AgentCard. The token URL is public configuration. The client secret never goes on the card.
Gate the JSON-RPC mount
Construct Authdog(public_key=...) from PK_AUTHDOG. Wrap the /a2a routes in middleware that calls require_auth and copies the subject onto the request before the A2A handler runs.
class _Gate(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
try:
user = await authdog.require_auth(request)
except HTTPException:
return JSONResponse(
{"error": "Unauthorized"},
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
)
request.state.authdog_subject = subject_of(user)
return await call_next(request)subject_of reads user["id"], then user["sub"]. A custom A2A context builder copies authdog_subject into context.call_context.state. The executor replies with subject:<id> and does not look at the raw Authorization header.
SendMessage requires the A2A-Version: 1.0 header. Without it the SDK reports that 0.3 is not supported, which is a protocol error, separate from the 401.
Call it
response = await http.post(
token_url,
data={"grant_type": "client_credentials"},
auth=(client_id, client_secret),
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
token = response.json()["access_token"]
async with httpx.AsyncClient(
headers={"Authorization": f"Bearer {token}"},
) as http:
card = await A2ACardResolver(http, base_url).get_agent_card()
client = ClientFactory(
ClientConfig(httpx_client=http, streaming=False)
).create(card)The same HTTP client fetches the card and sends the message, so the bearer rides on both. Card fetch succeeds without a token. Message calls do not.
Mint again when expires_in is close. The sample client performs one exchange per run.
What you should see
GET /.well-known/agent-card.jsonwith no token returns 200 and includes the token URL andagent:invoke.POST /a2a/withA2A-Version: 1.0and no bearer returns401and{"error":"Unauthorized"}, plusWWW-Authenticate: Bearer. The body does not containsubject:.- The same call with a token userinfo accepts returns a message whose text is
subject:<id>.
Configure
| Variable | Process | Purpose |
|---|---|---|
PK_AUTHDOG |
Server | Public key. Selects the identity host. |
AUTHDOG_TOKEN_URL |
Server and client | Token URL written on the card and used for the exchange. |
A2A_PUBLIC_URL |
Server | URL advertised on the card for the JSON-RPC binding. |
AUTHDOG_CLIENT_ID / AUTHDOG_CLIENT_SECRET |
Client | Agent credentials. Server does not read them. |
A2A_BASE_URL |
Client | Origin the client resolves the card from. |
Related
| Read | To learn how to |
|---|---|
| Agent frameworks | See the same bearer on the other four hooks |
| Eve | Move the check onto a channel auth walk |
| Machine identity | Create the confidential client |