Developers love "bundled" APIs. They offer atomicity and efficiency, allowing you to chain complex state changes into a single network request. Security engineers, however, should fear them. Bundling introduces complexity, and complexity is where the bugs hide.
As part of my research at depthfirst, I recently discovered a vulnerability in Temporal’s ExecuteMultiOperation endpoint (CVE-2025-14986). It was an identity-binding bug: the outer request passed authorization for one namespace, but an inner operation carried a different namespace that the server used during request preparation.
Why This Matters (What is Temporal?)
For those unfamiliar, Temporal is the backbone of durable execution for companies like Netflix, Stripe, and Datadog. It ensures code runs reliably even if servers fail. When you find a bug in Temporal, it affects the reliability layer that major companies depend on.
City Gate
The vulnerability lived in ExecuteMultiOperation, a handler designed to execute a StartWorkflow and UpdateWorkflow command in a single transaction.
When a request hits this endpoint, Temporal correctly performs an authorization check on the outer namespace. If I am authenticated as AttackerNS, the system checks my permissions, resolves my namespaceID, and opens the gate.
// service/frontend/workflow_handler.go
func (wh *WorkflowHandler) ExecuteMultiOperation(
ctx context.Context,
request *workflowservice.ExecuteMultiOperationRequest,
) (_ *workflowservice.ExecuteMultiOperationResponse, retError error) {
// ...
// 1. AUTHORIZATION: The system validates the top-level namespace
namespaceName := namespace.Name(request.Namespace)
namespaceID, err := wh.namespaceRegistry.GetNamespaceID(namespaceName)
// ...
// 2. HANDOFF: The derived namespaceID is passed downstream
historyReq, err := wh.convertToHistoryMultiOperationRequest(namespaceID, request)So far, so good. The guard checked my ID, and I was allowed in.
Two Faces
The problem arises when the system unpacks the bundle. The ExecuteMultiOperation request contains a list of operations. These inner operations carry their own metadata, including a Namespace field.
In the helper function convertToHistoryMultiOperationItem, the logic splits. The code had two different sources of truth for "Who is this user?":
- The Verified Identity: The
namespaceIDpassed down from the authorization check. - The Untrusted Identity: The namespace in the
startReqJSON payload inside the bundle.
The bug was a discrepancy between which identity was used for request preparation (policies/aliases/schema) versus which identity was used for routing and persistence:
// service/frontend/workflow_handler.go
func (wh *WorkflowHandler) convertToHistoryMultiOperationItem(
namespaceID namespace.ID, // <--- The Verified Source (Attacker)
op *workflowservice.ExecuteMultiOperationRequest_Operation,
) (*historyservice.ExecuteMultiOperationRequest_Operation, string, error) {
// ...
if startReq := op.GetStartWorkflow(); startReq != nil {
var err error
// VULNERABILITY PART 1: The Logic Check
// The system uses the UNTRUSTED payload to calculate policies and aliases.
// It asks: "Does this payload conform to the rules of startReq.Namespace (Victim)?"
if startReq, err = wh.prepareStartWorkflowRequest(startReq); err != nil {
return nil, "", err
}
// ...
opReq = &historyservice.ExecuteMultiOperationRequest_Operation{
Operation: &historyservice.ExecuteMultiOperationRequest_Operation_StartWorkflow{
// VULNERABILITY PART 2: The Routing
// The system uses the VERIFIED ID to decide where to save the data.
StartWorkflow: common.CreateHistoryStartWorkflowRequest(
namespaceID.String(),
startReq, // ...but passes the payload configured for the Victim.
nil,
nil,
time.Now().UTC(),
),
},
}
// ...
}
}
Exploit
This vulnerability created a "Confused Deputy" scenario. We could pass authorization under one namespace, while influencing policy/schema evaluation using another.
Here's the structure of the exploit JSON request:
{
// The Envelope (Authorized & Verified)
"namespace": "AttackerNS",
"operations": [
{
"startWorkflow": {
// The Payload (Untrusted)
// The system uses THIS field to look up policies and schema
"namespace": "VictimNS",
// ...
}
}
// ...
]
}
I found two ways to exploit this mismatch:
1. Cross-Tenant Isoloation Breach
In a multi-tenant SaaS, Tenant A should never be able to interact with the configuration of Tenant B.
- Setup: I created a
VictimNSwith a unique, private database schema (customSearch Attributes). - Breach: I sent a request as
AttackerNSbut referenced theVictimNSin the payload. The system validated our data against the Victim's private schema but saved the result in our database. - Impact: I forced the system to cross the tenant boundary to resolve our data. Temporal "touched" the Victim's configuration to process our request, breaking tenant isolation.
2. "Bring Your Own Policy" Attack
In many organizations, Production and Dev environments are locked down with strict policies—execution timeouts, retry limits, and archival settings. A rogue developer cannot override these Temporal policies set by the organization admin.
But with this exploit, they can. A developer can authenticate validly against the Corporate Org, but point to the policy configuration of their Personal Account.
- Setup:
CorporateNSenforces strict governance (e.g., max execution time, rate limits). The developer'sPersonalNSis fully permissive. - Breach: The developer sends a request authorized for
CorporateNS, but the inner payload specifiesPersonalNS. The system validates the request against the permissivePersonalNSrules. - Impact: The workflow executes within the corporate environment but ignores its rules. The developer has effectively injected a shadow policy to override the organization admin's controls.
Patch & Remediation
The masked namespace worked because the server verified the mask on the outside, then trusted the face inside the bundle.
This vulnerability existed because the system accepted two different namespace identities inside a single request. Authorization was performed once on the outer namespace, but request preparation later trusted the inner namespace when deriving policies and schema. With the release of v1.27, Temporal enforces a simple invariant: the namespace referenced by inner operations must match the outer, authorized namespace.
The fix introduces a check to ensure Outer.ID == Inner.ID before any processing occurs:
// service/frontend/workflow_handler.go (Patched)
if startReq := op.GetStartWorkflow(); startReq != nil {
// [FIX] Validate that inner namespace matches outer authorized namespace
if startReq.Namespace != "" && startReq.Namespace != namespaceName.String() {
return nil, "", errMultiOpNamespaceMismatch
}
// ...
}
Timeline
- Dec 12, 2025: Vulnerability reported to Temporal Security.
- Dec 16, 2025: Patch committed internally (Commit cd79be6).
- Dec 18, 2025: Validation of fix.
- Dec 30, 2025: Public release of Temporal Server v1.27.x/1.28.x/1.29.x and CVE-2025-14986.
- Jan 05, 2026: security.txt updated with public credit.