Blog

qa2a tasks/get metadata leaks auth token

Your agent framework is persisting the caller's bearer token

A read-only task lookup returned the caller’s access token, because the runtime stored the whole request metadata with the task. Where to scrub, and the trade-off it costs.

5 min readCredentials, A2A

by Ares Networks

rec 1c0df79 records
  1. session
  2. badge
  3. write
  4. score
  5. route
  6. draft
  7. route
  8. lookup
  9. route

A read-only lookup should never hand back a secret. Ours did. An agent asked for the status of a task over the agent-to-agent protocol, and the response carried the original caller's access token in the task metadata. If you are searching for why an A2A tasks/get call leaks an auth token from metadata, this is the pattern, and the fix that held.

Nothing was broken in the usual sense. Every component did what it was written to do. The runtime took the incoming request, kept its metadata with the task so a later turn could pick it up, and served that task back to anyone allowed to read it. The bearer token rode along because it was in the metadata, and the metadata was treated as part of the task.

Request metadata is an envelope, not a record

The mistake is a category error. Request metadata is a transport envelope. It carries what one hop needs: who is calling, a trace id, a credential for this turn. A task record is what you keep: the objective, the steps, the result. When code copies the envelope into the record, every secret in the envelope gets the lifetime of the record.

That lifetime is long. Tasks get written to a database, put on a queue for a worker, cached, logged on failure, and served back on lookup. Each of those is a place a token can surface. The token was short-lived, which limited the damage. A short-lived secret in a long-lived record is still a secret in the record.

So the rule we now apply everywhere: anything that writes metadata to a database, a queue or a served response scrubs it first. No exceptions for internal stores. Internal stores get backed up, exported, and read by people debugging late at night.

Scrub at the storage boundary

Our first instinct was to remove the token in the handler that builds the task. That handler knew about tokens today. The next handler, written next quarter by someone else, would not.

We moved the scrub to the storage boundary instead, and made it run in three places:

  • On write. The store removes credential-shaped keys before a task is persisted or queued.
  • On read. The store scrubs again before a task leaves it, so a row written by an older build is still clean on the way out.
  • Once at boot. A one-off pass rewrites rows written before the fix, then records how many it touched.

The scrub works on key names and value shapes. It never looks at what it removes, and it never logs it. The boot pass reports a count of rows rewritten, not a value. A log line with the token in it is the same leak in a new place.

scrub.ts
1const CREDENTIAL_KEYS = /^(authorization|cookie|x-api-key|.*token.*|.*secret.*)$/i2
3export function scrubMetadata(meta: Record<string, unknown>) {4  let removed = 05  const clean: Record<string, unknown> = {}6  for (const [key, value] of Object.entries(meta)) {7    if (CREDENTIAL_KEYS.test(key)) {8      removed++9      continue10    }11    clean[key] = value12  }13  // callers log `removed`, never the dropped values14  return { clean, removed }15}

The worker still needs the credential

There is a real trade-off here, and it is worth saying out loud. A worker that picks a task off the queue may need to act as the caller for that turn. If the queued body no longer holds the credential, where does it come from?

We keep it in a process-local vault with a short time to live, keyed by task. The queued body is credential-free. The worker in the same process takes the credential from the vault for the turn, and the vault forgets it when the turn ends or the time runs out.

The cost: a task that needs the caller's credential can no longer be picked up by an idle replica in another process. It has to run where the request arrived. We accepted that. Losing work-stealing on one class of task is cheaper than a queue full of bearer tokens.

Keep both guards

The orchestrator already had its own guard that stripped credentials before forwarding a task. After the runtime fix, it was tempting to delete one of them as redundant. We kept both. Defences that overlap are the point, not a smell.

We also wrote the test that proves the overlap matters. With both guards switched off, the lookup must leak again. If it does not, the test is not testing what we think it is. With either guard on alone, the lookup must be clean. That gives you three assertions instead of one, and it tells you which layer failed when one of them does.

What to take with you

  • Treat request metadata as an envelope. Do not copy it into anything you store or serve.
  • Scrub at the storage boundary: on write, on read, and once at boot for old rows.
  • Keep per-turn credentials in a process-local vault with a time to live. Queue credential-free bodies.
  • Keep overlapping guards, and test that switching both off brings the leak back.
  • Count what you rewrote. Never log a value.

On a governed network every hop gets a narrow, time-limited badge of its own, so there is no long-lived caller token to leak in the first place. Run a scenario in the sandbox and look at the session on each record.

Keep reading