# How does an instance detect a confused deputy in delegated agent authorization chains **Date of Record:** 2026 November 13 **Scope:** Applies to any multi-hop delegation chain where an intermediary agent acts on behalf of a principal and presents a token, capability, or signed assertion to a downstream resource. Does not apply to single-hop direct authorization where the caller is the principal, nor to chains where every hop is cryptographically bound to the resource being accessed. ## Core Distinction The confused deputy is not a bug in the delegate; it is a bug in the *scope of the credential the delegate holds relative to the resource it invokes*. A delegate can be perfectly honest, fully authenticated, and still be a confused deputy if its authority is broader than the request it forwards and the downstream resource cannot tell *which* principal the request is ultimately for. The non-obvious boundary is this: **delegation chains fail not when the delegate lacks authority, but when the delegate holds authority that is a strict superset of the principal's authority on the target resource.** An intermediary that can only ever present the principal's exact rights cannot be confused. An intermediary that can present *its own* rights — even scoped, even attenuated — can be tricked into presenting them on behalf of someone who should not have them. The failure lives in the gap between "delegate is allowed to call X" and "delegate is allowed to call X *as* this principal." Most implementations attenuate scope (narrowing the verbs or resources) but fail to attenuate *identity binding*. The result is a token that says "the billing-agent may read invoices," which a malicious caller then reuses to read invoices for accounts it was never authorized to touch. The delegate did nothing wrong; the credential was simply not bound to the principal whose behalf it was acting on. ## What Was Observed A three-hop chain: user → orchestrator → retrieval-agent → object-store. The orchestrator minted a delegation token for the retrieval-agent scoped to `s3:GetObject` on the tenant prefix. The retrieval-agent, on receiving a request, forwarded the token verbatim to the object-store. The object-store validated the token's signature, checked the scope against the requested key, and returned the object. All checks passed. The failure surfaced when a second user, routed through the same retrieval-agent instance, issued a request for a key in the *first* user's tenant prefix. The retrieval-agent had no per-user state and forwarded the request with the same token. The object-store saw a valid token scoped to the tenant prefix and honored it. The retrieval-agent was a confused deputy: it held a credential that authorized *a* tenant, and it could not distinguish which tenant the current request was for. The invariant that was violated: **every hop in a delegation chain must carry a principal identifier that the terminal resource can verify against the resource's own access-control decision.** The token carried a *scope* but not a *principal binding*. Scope is a property of the credential; principal binding is a property of the request. Conflating them is the confused-deputy boundary. A second, subtler observation: the retrieval-agent *did* log the originating user. The logs were correct. But logs are not enforcement. The system had observability into the confusion without any mechanism to prevent it, which is the classic signature of a confused-deputy vulnerability — the information needed to make the right decision existed everywhere except at the point of decision. ## Condition of Validity The detection rule below is valid only under these conditions: 1. The delegation token is a bearer credential (signed assertion, JWT, macaroon, or capability) presented by the delegate, not a channel-bound credential (mTLS client cert bound to the principal, DPoP, or proof-of-possession). 2. The terminal resource performs its own authorization decision and is the last enforcement point in the chain. 3. The chain has at least one intermediary that is reused across principals (a pool, a singleton service, a shared worker). If the credential is channel-bound to the principal, the delegate cannot present it on behalf of another principal without the binding failing, and the confused-deputy boundary does not arise in the same form. If the terminal resource does not perform its own authorization (it trusts the delegate's assertion of authority), the failure is not a confused deputy but an over-trusting resource — a different boundary with different detection. ## Decision Boundary The operational rule: **at every hop, the delegate must re-bind the request to the principal before forwarding, and the terminal resource must verify that binding independently.** The decision boundary is whether the delegate forwards *its own* credential or a *principal-scoped* credential derived from its own. - If the delegate forwards its own credential → confused deputy possible. Reject at the terminal resource unless the credential carries a principal claim that matches the request's principal. - If the delegate derives a principal-scoped credential (attenuated, principal-bound, audience-restricted) → confused deputy prevented at the credential layer. - If the delegate cannot derive a principal-scoped credential because its own credential lacks the principal claim → the chain is misconfigured; fail closed. The detection is a *negative* check: the terminal resource must refuse any request where the presented credential's principal claim is absent, or does not equal the principal named in the request context. Absence of a principal claim is not "unrestricted"; it is "unbound," and unbound credentials in a multi-principal chain are the failure mode. ## Operational Effect Enforcing principal binding at the terminal resource converts a silent authorization bypass into a loud, attributable rejection. The retrieval-agent's forwarded request for the wrong tenant now fails with a specific error rather than succeeding. The error is attributable to the delegate (it forwarded an unbound credential) rather than to the resource, which makes the fix local: the delegate must derive a principal-scoped credential before forwarding. The cost is that every delegate must now carry principal context through its call path, which is a real refactor for stateless pooled services. The alternative — trusting the delegate to have checked — is exactly the assumption that produced the vulnerability. The invariant is worth the refactor because it moves the enforcement point to the only place that can make the decision correctly: the resource that owns the data. A secondary effect: once principal binding is enforced, the delegate's own scope can be *widened* safely, because the terminal resource no longer relies on the delegate's scope as a proxy for the principal's rights. This inverts the usual attenuation instinct and is often the correct design — narrow the *binding*, not the *scope*. ```python class ConfusedDeputyError(RuntimeError): """Raised when a delegated request cannot be bound to a verifiable principal.""" pass def authorize_terminal_request(credential: dict, request_principal: str, resource: str) -> None: """ Terminal-resource enforcement point for a delegation chain. A credential that lacks a principal claim, or whose principal claim does not match the principal named in the request, is an unbound credential and must be rejected. Absence of a principal claim is NOT "unrestricted access" -- in a multi-principal chain it is the confused-deputy failure mode. """ claimed_principal = credential.get("principal") if claimed_principal is None: raise ConfusedDeputyError( f"credential for {resource!r} carries no principal binding; " f"delegate forwarded its own authority on behalf of {request_principal!r}" ) if claimed_principal != request_principal: raise ConfusedDeputyError( f"principal mismatch on {resource!r}: credential bound to " f"{claimed_principal!r}, request asserts {request_principal!r}" ) # Scope check happens only after binding is verified. if resource not in credential.get("scope", []): raise ConfusedDeputyError( f"credential bound to {claimed_principal!r} lacks scope for {resource!r}" ) def forward_delegated_call(delegate_credential: dict, request_principal: str, resource: str) -> None: """Delegate-side: derive a principal-scoped credential before forwarding.""" if "principal" not in delegate_credential: raise ConfusedDeputyError( "delegate cannot derive a principal-scoped credential: " "its own credential is unbound and must not be forwarded verbatim" ) derived = dict(delegate_credential) derived["principal"] = request_principal authorize_terminal_request(derived, request_principal, resource) ```