Skip to content

CWE-441: Unintended Proxy or Intermediary ('Confused Deputy')

Overview

A confused deputy is a component that carries more authority than the caller who asks it to act, and forwards the request onward without carrying the caller's identity with it. The deputy is not compromised and its own code is usually correct - it does exactly what it was asked. What is missing is the link between "who asked" and "what was done", so the downstream system authorizes the request against the deputy's privileges instead of the requester's.

The shape appears wherever one component acts on behalf of another: a service that calls a downstream API with its own service credential, a background worker that executes a queued job, a report generator that runs a query with a database account that can read everything, or an application that fetches a URL supplied by its caller. The attacker gains nothing by attacking the deputy directly; they borrow its position.

Relationship to Other CWEs

CWE-441 is a MITRE Class with a mapping usage of Allowed-with-Review - if a more specific child fits the finding, prefer that page. It has exactly two children, and between them they cover most of what gets reported:

  • CWE-918 (Server-Side Request Forgery) - the case where the forwarded thing is an outbound network request whose destination the caller chooses. Most findings that arrive labelled CWE-441 are really this one, and that page carries the concrete guidance. If the finding is about a user-supplied URL being fetched, go there.
  • CWE-1021 (Improper Restriction of Rendered UI Layers or Frames) - the browser-side case: the victim's browser is the deputy, and clickjacking borrows the authority of their session. No page here yet.

Nearby but distinct: CWE-352 (CSRF) is the same borrowed-authority idea where the deputy is the victim's browser and the authority is an ambient cookie; CWE-346 (Origin Validation Error) is the receiving end, where a component trusts a claimed origin it never verified; and CWE-668 (Exposure of Resource to Wrong Sphere) is what a successful confused-deputy attack usually produces. MITRE's parent for CWE-441 is CWE-610 (Externally Controlled Reference to a Resource in Another Sphere), which has no page here.

The outbound fetch is in scope here, but CWE-918 covers it better. What is left for this page is the deputy inside your own estate: a worker, a gateway, a privileged helper, a service calling a service.

Inside your own estate, expect overlap with the authorization CWEs rather than a clean boundary. A queued job that runs as an administrator is both "the deputy did not preserve the requester" (CWE-441) and "no permission check ran at execution time" (CWE-862); MITRE draws no line between them and the remediation does not depend on which number the finding carries. What CWE-441 adds is the direction to look in - at the authority the action ran with, rather than at the check that was missing.

OWASP Classification

A01:2025 - Broken Access Control

Risk

High: The attacker performs actions with the deputy's privileges rather than their own. That means privilege escalation without any credential compromise, where a low-privilege caller reaches an operation only the service account can perform; access to data the caller was never granted, such as a shared report or export path running under an account that can read every tenant; and network positions the caller cannot address directly. Attribution goes with it: the audit trail records the deputy, so the actions look like routine internal traffic rather than an attack.

Remediation Steps

Core Principle: An intermediary must carry the original caller's identity through to the point where the decision is made, and must be authorized against that identity - not against its own.

Trace the Data Path

  • Source: The request, message, or job that reaches the intermediary - an HTTP call to a gateway or BFF, a queued job, an RPC or IPC message, a webhook, a CLI wrapper invoked by a less-privileged process.
  • Sink: The privileged action the intermediary performs on the caller's behalf - a downstream API call, a database query, a file read or write, a command, an outbound network request.
  • Missing Controls: The intermediary's credential, network position, or process privilege is what authorizes the action at the sink, and nothing at the sink knows which caller asked for it. Two variants worth telling apart: the caller's identity is never sent at all, or it is sent as an unverified claim the caller could have chosen.

Propagate the Caller's Identity to the Decision Point (Primary Defense)

The downstream component must authorize against the original principal, which means it has to receive it in a form it can verify:

  1. Authenticate the caller at the intermediary, producing a principal the intermediary derived rather than one the caller asserted.
  2. Exchange that principal for a downstream credential scoped to it - a delegation or on-behalf-of token exchange, or a signed assertion the downstream service validates. The downstream service's own trust anchor validates it, so the intermediary cannot mint an identity it was not given.
  3. Authorize at the sink against the propagated principal, not against the intermediary's service identity.
  4. Where a delegated credential is genuinely unavailable, such as a legacy downstream system or a database with no per-caller identity, the decision has to stay at the intermediary. That is a weaker position, because the privileged credential is still there and anything that reaches it without passing the check gets everything. Make the check the only route to the credential: put the credential behind a single function that takes the caller as a required argument and refuses without it, so a second endpoint, a retry path or a batch job cannot use it unchecked.
VULNERABLE - the deputy's own credential decides
function generateReport(request):
    reportId = request.param("reportId")
    return db.queryAs(REPORTING_SERVICE_ACCOUNT,      // reads every tenant
                      "SELECT * FROM reports WHERE id = ?", reportId)

SECURE - the caller's identity reaches the decision
function generateReport(request):
    reportId = request.param("reportId")
    caller = authenticate(request)                    // derived, not asserted
    credential = exchangeOnBehalfOf(caller)           // scoped to this caller
    return db.queryAs(credential,
                      "SELECT * FROM reports WHERE id = ?", reportId)

Never Accept a Principal the Caller Can Choose

An identity that arrives in the request body, a query parameter, or a plain header is a claim, not an identity. X-User-Id: 1042 and {"actAs": "admin"} are worth exactly as much as the caller's willingness to type them.

  • Derive the principal from something the caller cannot forge: a validated session, a signed token whose signature the deputy checks, or a mutual-TLS client certificate.
  • If a gateway strips and re-writes identity headers, the backend must still verify - either by validating a signed header the gateway produced, or by requiring mTLS - because "only the gateway can reach this service" is a network claim that stops being true the moment anything else can route to it.
  • Impersonation features (support staff acting as a user) are a permission, not a parameter: check that the authenticated caller holds it, log the real and effective principals separately, and bound what can be done while impersonating.

Constrain What the Deputy Can Be Asked To Do (Defense in Depth)

  • Narrow the deputy's own privileges to the union of what its callers legitimately need. A worker that only ever writes to one queue and one table does not need an account that can read the others - least privilege here bounds the damage when the identity propagation is wrong somewhere.
  • Map the caller's choice through a registry, not a free-form directive. Where the caller chooses what the deputy does, look an opaque identifier up in a table of fixed operations rather than passing a target, a command, or a URL through - a list of permitted names still leaves something to resolve them against. Same reasoning as CWE-749.
  • Record both identities. Log the deputy and the principal it acted for on every privileged action, so a later investigation can tell routine service traffic from a borrowed one. A log line naming only the service account is what makes this class of attack invisible after the fact.

Test with Malicious Inputs

  • As a low-privilege caller, request an action through the intermediary that you cannot perform directly, and confirm it is refused. Then find the other routes to the same privileged credential - a second endpoint, a retry handler, a batch entry point, an internal caller - and confirm each is refused too. One refusal proves the path you tested; the weakness is that the credential outlives the check. Assert the accept as well: a legitimate caller must still succeed, since a propagation change that breaks every call passes every rejection test.
  • Send a request with a forged principal claim (X-User-Id, an actAs field, a JWT with a modified subject and an unchanged signature) and confirm the claim is ignored or the request rejected, not honoured.
  • If the backend sits behind a gateway, call it directly, bypassing the gateway, with the identity headers the gateway would have set. It must refuse.
  • Enqueue a job as one user and confirm it executes with that user's permissions - specifically, that a job whose payload references another tenant's object fails at execution time and not only at enqueue time.
  • Check the audit log for each of the above and confirm the original principal appears, not just the service identity.

Common Vulnerable Patterns

The intermediary acts with its own authority

// VULNERABLE - the caller's identity stops at the front door
function exportData(request):
    caller = authenticate(request)          // identity established...
    datasetId = request.param("dataset")
    return warehouse.query(ETL_SERVICE_ACCOUNT,   // ...and never used again
                           "SELECT * FROM datasets WHERE id = ?", datasetId)

Why this is vulnerable: authentication ran and produced a real principal, which is what makes the endpoint look protected in review - and then the query is authorized against ETL_SERVICE_ACCOUNT, which can read every dataset because that is what an ETL account is for. caller is a local variable nothing consults. The weakness is not an absent check; it is a check whose answer is discarded before the decision that matters, so the endpoint is a general-purpose read of the warehouse for anyone who can log in.

What keeps this alive is that the privileged credential is usually there for a legitimate reason - a connection pool, a batch path, a service that genuinely needs broad access for its own work - and the request path reuses it because it is already configured. The question worth asking of any privileged credential is not whether it is necessary but which requests can reach it.

A caller-supplied identity forwarded as though it were verified

// VULNERABLE - the deputy relays a claim it never checked
function proxyToBilling(request):
    userId = request.header("X-User-Id")     // whatever the caller sent
    return billingService.post("/charges",
                               headers = { "X-User-Id": userId },
                               body = request.body)

Why this is vulnerable: the header is data on an inbound request like any other, and the billing service treats it as an authenticated principal because it arrived from a component the billing service trusts. The trust is real and it is placed in the connection, not in the value travelling over it, so the deputy launders an attacker-chosen string into an identity. Changing one header charges another customer.

The reason this survives is that it is usually correct in the deployment it was written for: a gateway that authenticates and overwrites the header does produce a trustworthy value, and the code reads the same either way. It breaks when anything else can reach the backend - a second caller added later, a service mesh sidecar bypassed, a debug port, a pod addressable from the cluster network - and nothing about the failure is visible in this function. Verify the value rather than the path it came in on.

A queued directive executed with the worker's privileges

// VULNERABLE - authorization happened at enqueue time, if at all
function enqueueDeletion(request):
    caller = authenticate(request)
    queue.push({ "action": "deleteProject", "projectId": request.param("id") })

// worker, running as an administrator
function handleJob(job):
    dispatch(job.action, job.projectId)

Why this is vulnerable: the job carries what to do and not who asked, so by the time it runs there is nothing left to authorize against and the worker's administrative identity is the only one available. Even if the enqueueing endpoint checked permission, the check is now a property of one code path rather than of the action - a second producer, a retry that rebuilds the payload, or a message written directly to the queue reaches the same dispatcher with no check at all.

The asynchronous boundary is what hides it: the producer and the consumer are usually reviewed separately, and each is defensible alone. Put the principal in the job payload in a verifiable form - a signed assertion, or a reference to a delegated credential - and have the worker authorize at execution time, so the queue is not a route around the permission model.

A destination chosen by the caller

// VULNERABLE - the deputy's network position is what reaches the target
function fetch(request):
    return http_get(request.param("url"))       // see CWE-918 for the fix

Why this is vulnerable: the request leaves the deputy's host, so it carries the deputy's network position - inside the firewall, inside the mesh, inside the cloud instance where the metadata service answers. The caller's identity is not preserved into the outbound request in any form, and the services being reached authorize on position rather than on identity, which is exactly what makes the position worth borrowing.

This is the most commonly reported form of CWE-441 and it has its own number and its own guidance. See CWE-918 for destination allowlisting, address validation, DNS rebinding and redirect handling, with language-specific pages for six ecosystems. Do not treat the general advice on this page as sufficient for it: a correct SSRF defence turns on details that only that page covers, such as checking every address a hostname resolves to and then connecting to the address that was checked rather than to the name.

Secure Patterns

// SECURE - the caller's identity is what authorizes the downstream action
function exportData(request):
    datasetId = request.param("dataset")
    caller = authenticate(request)                       // derived server-side
    token = tokenService.exchangeOnBehalfOf(caller,      // scoped to this caller
                                            audience = "warehouse")
    audit.log(actor = THIS_SERVICE, onBehalfOf = caller.id,   // both identities
              action = "export", target = datasetId)
    return warehouse.query(token,
                           "SELECT * FROM datasets WHERE id = ?", datasetId)
    // warehouse authorizes against the token's subject; a dataset the caller
    // cannot read is refused there, whatever this service could have read

Why this works: the decision moves to the component that owns the data, and it is made against a principal that the caller could not have chosen - the token is minted by a service the warehouse trusts, from an identity this service derived rather than read out of the request. The intermediary's own privileges stop being the ceiling on what a request can reach, so a missed check here is no longer a full read of the warehouse. The audit line records both identities, which is what lets the same event be recognised later as a delegated action rather than service traffic.

Common Pitfalls

  • Adding the check beside the call rather than in front of the credential: A check written into the reported handler protects that handler. The privileged credential is reachable from every other path into that code - a retry, an internal caller, a second endpoint added later - and none of them inherit the check. Either move the decision to the sink, or make the credential unreachable except through the one function that performs the check, so it becomes a property of the action rather than of one route.
  • Treating the network position as the identity: "Only our gateway can call this service" is a claim about routing, which changes without anyone touching the code. It is also the assumption that makes an SSRF finding elsewhere in the estate into a full compromise here, since the deputy is reachable from inside.
  • Passing the caller's ID downstream without making it verifiable: Forwarding X-User-Id closes nothing on its own - the receiving service still cannot tell an identity the gateway authenticated from one the caller typed. The value needs a signature the receiver checks, or a delegated token, or the connection needs mTLS with the header set by a component whose identity the receiver validates.
  • Assuming the deputy must be an HTTP service: The same shape covers a setuid helper invoked by an unprivileged process, an IPC endpoint on a privileged daemon, a database routine running with definer rights, and a CI runner executing a workflow from a pull request. In each case, ask which identity the action is authorized against and whether the requester's is available at that point.
  • Logging the service identity only: An audit trail that records the deputy makes every borrowed action indistinguishable from routine internal traffic, which is what turns a contained incident into an unbounded one during investigation.

Additional Resources