Chapter 3 of 7 · 12 min read
The human principal has to travel as a verifiable claim
On this page
The previous chapter left a working credential in the agent's hands that says which service it may call, which operations it may perform and for how long. What it does not say is who the agent is acting for, and every entitlement question downstream — may this person export this data, may this person invite this contractor — needs that. This chapter is about how the human principal travels alongside the credential in a form the receiving service can verify, and about what happens in the common arrangement where it travels in the request body instead.
The reporting service received this at 14:06 and answered it in 900 milliseconds:
POST /v2/exports HTTP/1.1
Host: reporting.internal.example.com
Authorization: Bearer eyJhbGciOiJFUzI1NiIsImtpZCI6IjJ...
Content-Type: application/json
{"user_id": "u_1002", "dataset": "revenue_by_account", "range": "2025-01-01..2026-03-31"}The token validated. Its audience was the reporting service, its scope was exports:create, it had four minutes left, and the agent it was issued to was on the allowed list. The export ran, produced 61 MB of revenue data covering every account in the tenancy, and emailed a signed URL to the address on record for u_1002.
The person who had been talking to the assistant was u_8831, a contractor with access to two accounts. The string u_1002 had come out of the model, which had picked it up from a stale summary earlier in the conversation, and no component in the path had any way to notice, because user_id was a field in a JSON body that the agent had composed.
Follow why that is a category error rather than a missing validation, because the reporting service did more work than a careless one would have. It looked up u_1002, it consulted the entitlement store, and it confirmed that u_1002 was allowed to export revenue data. Every step of that is a real check. The step that is missing is upstream of all of them: the identity being checked arrived in a field the caller wrote, so the service checked the entitlement for a principal that the caller had nominated. An authorisation check is a comparison between a claim and a policy, and it is worth exactly as much as the weaker of the two — a policy no caller can edit, compared against an identity every caller can. That is not an entitlement check; it is a request for the caller's opinion about who they are, and this is the shape of the bug even in codebases where every other layer is careful.
Two principals, one token
What the reporting service needed was the human principal delivered by something it trusts, which in practice means the authorisation server that minted the token, cryptographically bound to the rest of the token by the same signature. That binding is the mechanism, and it is worth being concrete about what makes it hold: the signature covers the whole payload, so an attacker who edits sub invalidates the signature over aud, scope and exp at the same time, and there is no way to change one field without producing a token no resource server will accept. The exchange from the previous chapter is where that binding gets made, because the app server verifying the session is the last component in the chain that has first-hand knowledge of who the user is.
The standard shape carries both principals in one token: sub names the human, and a nested act claim names the actor operating on their behalf.
{
"iss": "https://auth.internal.example.com",
"sub": "u_8831",
"act": {
"sub": "agent:reporting-assistant",
"ver": "v2026-03-04",
"azp": "svc:web-bff"
},
"aud": "https://reporting.internal.example.com",
"scope": "exports:create",
"resource": ["acct_88213", "acct_88477"],
"auth_time": 1772596180,
"exp": 1772596620,
"jti": "tok_01HQ8ZK9PP"
}Read it as a sentence: u_8831 is the principal whose authority is being spent, agent:reporting-assistant at build v2026-03-04 is the software spending it, and svc:web-bff is the client that requested the exchange, in this case the back-end-for-frontend service that sits behind the web app. The delegation is explicit in both directions, which is what distinguishes it from impersonation. An impersonation token would have sub: u_8831 and nothing else, and the reporting service would believe a human made the request, which throws away information every later chapter needs — the audit record of chapter seven cannot say whether a person or a model chose an action if the token never said a model was involved.
The corresponding change on the resource server is small and load-bearing:
def create_export(request) -> Response:
claims = verify_token(
request.headers["authorization"],
audience="https://reporting.internal.example.com",
issuer="https://auth.internal.example.com",
) # raises on bad signature, wrong aud, expired
principal = claims["sub"] # the human, from the issuer
actor = claims.get("act", {}).get("sub") # the agent, from the issuer
body = request.json()
if "user_id" in body and body["user_id"] != principal:
return Response(403, "user_id does not match token subject")
if not entitlements.may_export(principal, body["dataset"]):
return Response(403, f"{principal} may not export {body['dataset']}")
return run_export(for_user=principal, dataset=body["dataset"], actor=actor)The issuer argument to verify_token is doing as much work as the audience one, and it is easy to read past. A signature proves that whoever holds a particular signing key produced the payload, and it says nothing about whether that key is one you have any reason to trust. A verifier that accepts any well-formed signature accepts tokens minted by anybody who can run a signing library, so the issuer has to be pinned to the authorisation server whose keys the service fetches. The sub inside the token is worth something only because exactly one party is allowed to fill it in, and pinning the issuer is what makes that true at the point of use.
Two lines carry the fix. The principal is read from claims["sub"] rather than from the body, and the body's user_id is treated as a redundant assertion that must agree — a field that many clients will keep sending for years, and which is now a consistency check rather than an input. That inversion is the whole change: the same value is present in both versions of the code, and what differs is which copy the decision is taken from. Running that code against the 14:06 request produces a 403 in about 2 ms, because u_8831 does not match u_1002, and the model's stale summary becomes a logged denial rather than 61 MB of revenue data.
What each hop can check, and what it is trusting
The claim is only as strong as the weakest place it is produced or consumed, so it is worth walking the chain and being precise about which assurances are cryptographic and which are architectural. A cryptographic assurance is one the receiver can verify from the message alone, so it survives a compromised neighbour. An architectural one is a belief about how the system is arranged, and a neighbour that stops behaving as arranged takes it with them.
| Hop | Verifies cryptographically | Takes on trust |
|---|---|---|
| Browser → app server | Session cookie signature, CSRF token, TLS | That the device is not itself compromised |
| App server → token service | Its own client credentials; the internal user assertion it just signed | That its session verification was correct |
| Token service → issued token | Nothing to verify; it is the source of truth for sub and act | That the requesting client is entitled to act for this subject |
| Agent loop → MCP server | TLS and the server's identity | Nothing about the token's contents; it is a bearer of an opaque string |
| MCP server → internal API | TLS | That it forwards the token unmodified rather than swapping in its own |
| Internal API → decision | Issuer signature, aud, exp, sub, act | That the issuer only mints sub values it verified |
Read the two columns as a gap rather than as a scorecard. The rows at the top and bottom have short right-hand columns and long left-hand ones, and the rows in the middle have the reverse — which is a map of where a compromise propagates. The row that surprises people is the agent loop. It cannot verify anything about the token it carries and does not need to, because it is a courier. That is the correct posture for a component whose behaviour is influenced by untrusted text: it holds a string it cannot read, cannot extend, and cannot re-target, and every property that constrains it was decided by components it has no influence over.
The row that carries the most risk is the token service, and the reason is visible in its right-hand column. If the exchange endpoint accepts a subject parameter supplied by the caller and mints a token with that value in sub, then the whole scheme reduces to the JSON body problem one hop earlier, in a component whose output every downstream service trusts absolutely. The exchange must require proof of the subject — the internal assertion the app server signed after verifying the session, or an ID token from the identity provider, which is a signed statement that a named person authenticated at a named time — and it must record which client requested the exchange, which is what the azp field above is for. Google's ADK documents this as the difference between an agent that is configured with a user's identity and an agent that presents evidence of a user's presence; the second one is auditable and the first one is a shared password with extra steps.
When the call has more than one hop
Real estates are not one hop deep. The reporting service, having accepted the export, calls the warehouse service to run the query, and the warehouse service calls object storage to write the result. If the reporting service presents its own service credential to the warehouse, the human principal disappears at the first internal boundary, and the warehouse is back to acting on the strength of who asked rather than on whose behalf.
Two shapes are workable and they differ in where the burden falls. The first is to propagate the same token, which requires that the warehouse be an accepted audience — either by minting with multiple audience values, which weakens the boundary from chapter two, or by having the reporting service exchange its token for a new one bound to the warehouse. The exchange preserves the chain: the new token keeps sub: u_8831 and grows the actor claim, so act.sub becomes svc:reporting and the previous actor is nested inside it as act.act. Nesting rather than replacing is what makes the chain reconstructable — each exchange adds a layer and destroys none, so anyone reading the token at the warehouse can reconstruct the full path from the human through the agent to the calling service, and each link was asserted by the issuer rather than by the caller.
The second shape is to stop propagating at a deliberate boundary and record why. Object storage has no notion of u_8831 and will never evaluate an entitlement for a human, so the honest design is for the reporting service to make the final authorisation decision itself, then write with its own credential to a path derived from the decision — s3://exports/u_8831/2026-03-11/… — and log the decision id alongside. What makes this safe is that the authority was spent one hop earlier, at the last component that could evaluate it. What makes it dangerous is doing it accidentally, three services deep, at a component nobody thought of as an authorisation point.
The failure to avoid is the middle option that looks like the first: the reporting service copies sub into a plain header, X-On-Behalf-Of: u_8831, and the warehouse reads it. That header is unsigned, so its value carries exactly the assurance of the JSON body at the start of this chapter, and it degrades to "whatever the calling process wrote". The reason it survives review is that it produces identical behaviour to the correct version in every test anybody writes: the header holds the right value, the warehouse reads the right value, the entitlement resolves the same way. It is tempting because it costs one line and works in the demo, and it is only distinguishable from the correct version under the assumption that some component in the estate is compromised — which is the assumption the whole design exists to survive.
Where this stops short
A verified sub establishes on whose authority the call is made. It does not establish that the human asked for this call, and the difference is easy to lose. When the assistant, mid-run, decides that exporting revenue data would help answer the question it was given, the resulting request carries sub: u_8831 truthfully — that is genuinely the user's authority being spent, because the user's session is what produced the token, and if u_8831 is entitled to export revenue data the reporting service will and should allow it. What the token cannot say is whether the export was the user's idea. Chapter seven takes that question up as an audit problem, because the record has to distinguish the two even when the authorisation is identical in both cases.
The claim also says nothing about freshness of intent. auth_time in the payload above tells a resource server when the human last authenticated, which is a weak proxy that some services use to demand step-up for sensitive operations, meaning a fresh authentication before the operation is allowed to proceed, and it is a proxy rather than an answer. A user who signed in at 13:40 has an auth_time of 13:40 whether they are watching the agent work or have closed the laptop and gone to lunch.
And a two-principal token does not, on its own, cause anyone to check anything. This is the limit worth carrying into the next chapter, because it is the one that survives every improvement in this one. Every service in the estate can receive a perfectly formed token carrying sub and act, validate its signature and audience, and then perform the operation on the strength of its own privileges without ever reading sub — which is the reporting service's original behaviour, with a better-populated token going unused. Putting the human principal on the wire is a necessary condition for an entitlement check and not a sufficient one; the claim has arrived, and something still has to read it at the point where authority is spent. That failure has a name and a history, it survives correctly scoped agents, and reproducing it is how the next chapter shows where the check has to sit.
Agent identity and delegated authority — read the current version at https://daviddominguez.dev/learn/en/agent-identity-and-delegated-authority/the-human-principal-has-to-travel-as-a-verifiable-claim