server/interceptors

May 21, 2026 · View on GitHub

Custom gRPC interceptors wired into the Temporal frontend via temporal.WithChainedFrontendGrpcInterceptors. Each interceptor is observe-only — requests are always allowed through.

Table of contents


PlainTextPayloadInterceptor

What it does

Scans inbound frontend API calls for payloads whose encoding metadata indicates they are unencrypted (json/plain or binary/plain). When an unencrypted payload is detected it:

  1. Increments a Prometheus counter (plaintext_payload_detected_total) tagged with enough context for cluster admins to identify the offending tenant, workflow type, and task queue.
  2. Logs a WARN line with the same fields via the server's structured logger.

The request is always allowed through. This interceptor is intended to give operators visibility into tenants who have not yet deployed a payload codec — it is not a hard enforcement gate. See Extending to block requests if you want to harden it later.

binary/null (the encoding for nil / empty payloads) is intentionally excluded — null payloads carry no data and are not a privacy concern.

Covered APIs

The interceptor checks the following frontend methods. Each entry shows which metric tags are populated beyond the always-present namespace, operation, payload_field, and encoding tags.

API methodPayload field(s) checkedworkflow_typetask_queue
StartWorkflowExecutioninput
SignalWithStartWorkflowExecutioninput, signal_input
SignalWorkflowExecutioninput
QueryWorkflowquery.query_args
UpdateWorkflowExecutionrequest.input.args
RespondActivityTaskCompletedresult
RecordActivityTaskHeartbeatdetails
RespondWorkflowTaskCompletedper-command (see below)✓ (child/CAN only)✓ (child only)
RespondWorkflowTaskFailedfailure (see below)
RespondActivityTaskFailedfailure (see below), last_heartbeat_details
ExecuteMultiOperationdelegates to inner StartWorkflow and UpdateWorkflow operations✓ (from Start)✓ (from Start)
CreateScheduleschedule.action.start_workflow.input only — spec is not scanned
UpdateScheduleschedule.action.start_workflow.input only — spec is not scanned

For RespondWorkflowTaskCompleted, the interceptor walks the commands list and checks:

Command typeField checkedpayload_field tag value
ScheduleActivityTaskinputScheduleActivityTask
CompleteWorkflowExecutionresultCompleteWorkflowExecution
CancelWorkflowExecutiondetailsCancelWorkflowExecution
SignalExternalWorkflowExecutioninputSignalExternalWorkflowExecution
StartChildWorkflowExecutioninputStartChildWorkflowExecution
ContinueAsNewWorkflowExecutioninputContinueAsNewWorkflowExecution

The payload_field value for commands is derived from cmd.GetCommandType().String() — the proto enum's own string representation — so it will always match the canonical command type name without any manual mapping.

Why workflowType and taskqueue are blank on worker-side operations

For worker-side calls (RespondWorkflowTaskCompleted, RespondActivityTaskCompleted, RespondWorkflowTaskFailed, RespondActivityTaskFailed, RecordActivityTaskHeartbeat) the workflowType and taskqueue metric tags will always be empty strings.

The reason: these requests do not carry workflow type or task queue as top-level fields. That context is encoded inside the opaque task_token bytes — a serialized internal server protobuf that the worker receives from PollWorkflowTaskQueue / PollActivityTaskQueue and echoes back verbatim. The server decodes it internally to route the response to the correct history shard; the frontend interceptor sees only raw bytes and cannot extract workflow or activity type without deserializing internal server data structures.

Exceptions within RespondWorkflowTaskCompleted commands: StartChildWorkflowExecution carries workflow type and task queue directly in its command attributes (the child's type and target queue), and ContinueAsNewWorkflowExecution carries the workflow type. These are populated where available — see the commands table above.

The tags are still always emitted as empty strings rather than omitted entirely. This is a Prometheus requirement: all time series for a given metric must share the same set of label names. If worker-side series omitted workflowType and taskqueue while client-side series included them, Prometheus would reject the inconsistent series. The empty-string value makes it unambiguous that the information is not available for this call type, rather than missing by accident.

Why keep these metrics at all without type context? A worker pod that ships without a data converter registered will show up as RespondWorkflowTaskCompleted or RespondActivityTaskCompleted unencrypted traffic even while StartWorkflowExecution remains clean — because the client still has the codec configured but the worker does not. Namespace alone is enough to identify the affected tenant and trigger an investigation.

Failure scanning

RespondWorkflowTaskFailed and RespondActivityTaskFailed carry a Failure message, which is walked recursively through its cause chain. The following fields are checked:

Fieldpayload_field tag valueNotes
Failure.encoded_attributesencoded_attributesSingle Payload produced by the failure converter. Unencrypted encoding here means the failure converter is not encrypting.
ApplicationFailureInfo.detailsApplicationFailureInfo.detailsApplication error details (ApplicationError in SDKs).
CanceledFailureInfo.detailsCanceledFailureInfo.detailsCancellation details.
TimeoutFailureInfo.last_heartbeat_detailsTimeoutFailureInfo.last_heartbeat_detailsLast heartbeat payload on timeout.

The payload_field values for failure-info fields are derived from the proto message descriptor name (e.g. ApplicationFailureInfo.ProtoReflect().Descriptor().Name()) combined with the field name, so they always match the canonical proto type name.

RespondActivityTaskFailed also carries last_heartbeat_details directly on the request — this is scanned as a regular Payloads field with payload_field = "last_heartbeat_details".

Metric

plaintext_payload_detected_total  (counter)

Tag key names match the conventions used by the Temporal server's own metrics package.

TagKey nameAlways presentValues / notes
namespacenamespaceVia metrics.NamespaceTag
operationoperationShort API method name, e.g. StartWorkflowExecution
payload fieldpayload_fieldPayload field or command type: input, result, query_args, ScheduleActivityTask, etc.
encodingencodingjson/plain or binary/plain
workflow typeworkflowTypePopulated for Start, SignalWithStart, StartChild, ContinueAsNew commands. Empty string for all other operations — see note below.
task queuetaskqueuePopulated for Start, SignalWithStart, StartChild commands. Empty string for all other operations — see note below.

Each counter increment represents one unencrypted Payload object. A single StartWorkflowExecution with three payloads in its input will produce three increments.

Log output

Each detection emits a structured WARN line. Example (Zap JSON):

{
  "level": "warn",
  "msg": "unencrypted payload detected",
  "namespace": "my-namespace",
  "operation": "StartWorkflowExecution",
  "payload_field": "input",
  "encoding": "json/plain",
  "workflow_type": "MyWorkflow",
  "task_queue": "my-task-queue"
}

Grafana / PromQL

Rate of unencrypted payloads by namespace — table panel

sum by (namespace, operation, workflowType, taskqueue, payload_field) (
  rate(plaintext_payload_detected_total[5m])
)

Use as a table panel. Namespaces with a non-zero value have at least one worker or client sending unencrypted payloads.

Alert: any unencrypted payload traffic in the last 10 minutes

sum by (namespace, operation) (
  increase(plaintext_payload_detected_total[10m])
) > 0

Fire at severity warning. Label the alert with namespace so each offending tenant produces its own alert instance.

Alert: sustained unencrypted traffic (not just a one-off)

sum by (namespace, operation, workflowType, taskqueue) (
  rate(plaintext_payload_detected_total[30m])
) > 0

A 30-minute window filters out transient noise from development workflows. Use this to page the tenant.

Breakdown by entry point — useful during a migration

sum by (namespace, operation, payload_field, encoding) (
  rate(plaintext_payload_detected_total[5m])
)

Shows whether unencrypted payloads are coming in from client-side calls (StartWorkflowExecution, SignalWorkflowExecution) or from workers (RespondWorkflowTaskCompleted, RespondActivityTaskCompleted). Client-side and worker-side unencrypted traffic require different fixes from the tenant.

Wiring

The interceptor is instantiated in server/main.go and passed the same metricsHandler used by the Temporal server and the etcd dynconfig client, so its metric is emitted into the same Prometheus registry on the same HTTP listener.

plainTextInterceptor := interceptors.NewPlainTextPayloadInterceptor(logger, metricsHandler)

temporal.NewServer(
    // ...
    temporal.WithChainedFrontendGrpcInterceptors(plainTextInterceptor.Intercept),
)

The interceptor runs after the server's built-in auth and rate-limit interceptors, so namespace is already validated by the time check is called. No additional namespace lookup is needed.