February 5, 2026

The Masked Namespace Vulnerability In Temporal CVE-2025-14986

Mav Levin

Founding Security Researcher

Stay up to date on depthfirst.

Contact Details
Sign Up
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

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?":

  1. The Verified Identity: The namespaceID passed down from the authorization check.
  2. The Untrusted Identity: The namespace in the startReq JSON 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 VictimNS with a unique, private database schema (custom Search Attributes).
  • Breach: I sent a request as AttackerNS but referenced the VictimNS in 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: CorporateNS enforces strict governance (e.g., max execution time, rate limits). The developer's PersonalNS is fully permissive.
  • Breach: The developer sends a request authorized for CorporateNS, but the inner payload specifies PersonalNS. The system validates the request against the permissive PersonalNS rules.
  • 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.
Button Text

Secure your code to ship faster

Link your Github repo in three clicks.

Demo depthfirst now