{"slug": "we-built-hash-chained-workflow-histories-to-make-agent-execution-tamper-evident", "title": "We built hash-chained workflow histories to make agent execution tamper-evident", "summary": "Dapr has introduced cryptographic tamper detection for workflow execution histories by signing each history event with the sidecar's mTLS identity, creating an auditable hash chain. The feature requires careful root CA lifecycle planning, as expired or rotated roots will invalidate all signed workflows. This ensures that workflow events cannot be modified, reordered, or removed after being written.", "body_md": "# Workflow history signing\n\nDapr workflow history signing provides cryptographic tamper detection for workflow execution histories. Every history event produced during a workflow’s lifetime is signed using the sidecar’s mTLS identity (X.509 SPIFFE Verifiable Identity Document (SVID)), creating an auditable chain of signatures that is verified each time the workflow state is loaded.\n\n#### Before you enable signing: plan your root CA lifecycle\n\nWorkflow history signing trusts your Dapr **root CA**. The default Dapr-generated\nself-signed root is valid for **one year**. If that root expires, or if you\nrotate to a new root with a different private key, **every signed workflow\nissued under the old root stops verifying** and fails to load with error type\n`SignatureVerificationFailed`\n\n. There is no re-sign path.\n\nBefore turning the feature on, decide which of the following you will commit to:\n\n**Renew the leaf/issuer with the same root key**(recommended). Back up the Dapr-generated root private key now and reuse it for every renewal, or** Bring your own CA**with a root key you control and store securely (HSM or secret store), and reuse it for all issuer renewals, or** Drain before rotating to a new root.**Only run workflows short enough to complete (or be purged) inside one root-CA validity window, and complete or purge all signed workflows before rotating the root.\n\nIf you cannot guarantee one of these for the full lifetime of your longest\nworkflow, **do not enable signing yet**. See\n[long-running workflows and root CA expiry](/developing-applications/building-blocks/workflow/workflow-history-signing/#long-running-workflows-and-root-ca-expiry)\nfor the full guidance.\n\n## About SPIFFE Verifiable Identity Documents (SVIDs)\n\nAn SVID is the workload’s digital passport. Each Dapr sidecar gets one from Sentry and uses it both for mTLS and for signing workflow history.\n\n**SPIFFE ID**: embedded in the X.509 certificate (in the URI Subject Alternative Name) as`spiffe://<trust-domain>/ns/<namespace>/<app-id>`\n\n. It identifies the workload that produced the signature.**Cryptographic proof**: the sidecar holds the matching private key and uses it to sign each history batch.** Trust roots**: every SVID chains to a Sentry CA. Verifiers accept a signature only if its certificate chains to a CA in the trust bundle.\n\nFor background on Sentry, mTLS, and trust domains, see [setup & configure\nmTLS](https://v1-18.docs.dapr.io/operations/security/mtls/) and [security concepts](https://v1-18.docs.dapr.io/concepts/security-concept/).\n\n## Overview\n\nWorkflows in Dapr execute as a series of deterministic replay steps. Each step\nappends history events to the [actor state store](https://v1-18.docs.dapr.io/developing-applications/building-blocks/workflow/workflow-architecture/). History signing ensures that those events have\nnot been modified, reordered, or removed after they were written.\n\nWhen signing is active, Dapr:\n\n- Deterministically marshals each new history event.\n- Computes a SHA-256 digest over the batch of events.\n- Chains the new digest to the previous signature’s digest.\n- Signs the combined input using the sidecar’s\n[SPIFFE](https://spiffe.io/)X.509 private key (SVID). - Persists the signature and the signing certificate alongside the history.\n\nOn every subsequent load of that workflow’s state, Dapr walks the full signature chain and verifies every link before allowing execution to continue.\n\nEach signature covers a contiguous range of events and references the previous signature’s digest, forming a hash chain. A certificate table stores the DER-encoded X.509 certificate chains used for signing, indexed by position. When the sidecar’s SVID rotates (for example, after a restart), a new certificate entry is appended and subsequent signatures reference the new index.\n\n## Prerequisites\n\nHistory signing requires [mTLS](https://v1-18.docs.dapr.io/operations/security/mtls/) to be enabled. mTLS provides the SPIFFE\nX.509 identity that is used as the signing key. Without mTLS, there is no\nidentity material available and signing is silently disabled.\n\nIn a standard Dapr deployment with the [Sentry service](https://v1-18.docs.dapr.io/concepts/security-concept/), mTLS is enabled by default.\n\n## Configuration\n\nHistory signing is controlled by the `WorkflowHistorySigning`\n\nfeature flag. It is\n**disabled by default** and must be explicitly enabled.\n\n### Enabling signing\n\nTo enable signing, set the feature flag to `true`\n\nin your Dapr configuration:\n\n```\napiVersion: dapr.io/v1alpha1\nkind: Configuration\nmetadata:\n  name: my-config\nspec:\n  features:\n    - name: WorkflowHistorySigning\n      enabled: true\n```\n\n### Conditions for signing to be active\n\nBoth conditions must be true for signing to occur:\n\n| Condition | How to check |\n|---|---|\n| mTLS is enabled | Sentry service is running and the sidecar has a valid SVID |\n`WorkflowHistorySigning` is enabled | Feature flag is explicitly set to `true` |\n\nIf mTLS is disabled (no Sentry), the signer is `nil`\n\nregardless of the feature\nflag, and signing does not occur.\n\n#### Important\n\n**Signing is a one-way commitment.** Once a workflow is created with signing enabled, it must always run on signing-enabled hosts. Disabling signing on a host that loads a previously signed workflow will cause the workflow to fail. Similarly, enabling signing on a host that loads a previously unsigned workflow will cause the workflow to fail. See\n\n[one-way commitment](/developing-applications/building-blocks/workflow/workflow-history-signing/#one-way-commitment)for details.\n\n### Scope\n\nSigning is applied at the sidecar level. When the feature flag is enabled on\na Dapr configuration, **every workflow that runs on a sidecar using that\nconfiguration is signed** - there is no per-workflow-type or per-instance\nopt-in. If you need to introduce signing only for a subset of workflows, run\nthose workflows under a separate appID with a configuration that enables the\nfeature.\n\n### Activating signing on an existing deployment\n\nBecause signing is a [one-way commitment](/developing-applications/building-blocks/workflow/workflow-history-signing/#one-way-commitment), turning the\nfeature on against a cluster that already has running unsigned workflows will\ncause those in-flight workflows to fail to load. You do **not** need a brand\nnew deployment, but you do need to drain unsigned work before flipping the\nflag. A typical rollout looks like:\n\n- Stop scheduling new workflow instances on the appIDs that will get signing.\n- Allow in-flight workflows to complete naturally, or\n[purge](https://v1-18.docs.dapr.io/developing-applications/building-blocks/workflow/howto-manage-workflow/)ones you don’t need to keep. - Confirm there is no unsigned workflow state remaining for those appIDs.\n- Update the Dapr configuration to set\n`WorkflowHistorySigning`\n\nto`true`\n\n. - Wait for the configuration change to propagate to the sidecars (Dapr\n[hot-reloads](https://v1-18.docs.dapr.io/operations/components/component-updates/)configurations by default, so no restart is needed; just allow time for propagation across all sidecars). - Resume scheduling. New workflow instances are signed from the first event.\n\nIf you cannot drain in-flight workflows, run a parallel appID (with signing enabled) for new work and let the original appID complete its workflows without signing. Once the original appID has no remaining instances, retire it.\n\n## One-way commitment\n\nSigning is a permanent commitment per workflow. Once a workflow is created with signing enabled, all subsequent operations on that workflow must occur on signing-enabled hosts. There are two invariants:\n\n**Signed workflow on non-signing host**: If a workflow has signed history but the current host does not have a signer configured (mTLS is off or the feature flag is disabled), loading the workflow fails. The workflow cannot execute and is effectively terminated.**Unsigned workflow on signing host**: If a workflow was created without signing (the feature flag was off) and is later loaded by a signing-enabled host, loading fails. The unsigned history has no integrity proof and cannot be retroactively signed.\n\n#### Migration guidance\n\nBefore enabling signing cluster-wide, ensure all existing unsigned workflows have completed or been purged. Once signing is enabled, new workflows are signed and existing unsigned workflows fail to load.## Certificate rotation\n\nDapr handles certificate rotation transparently. When the sidecar’s SVID rotates (for example, after a restart where Sentry issues a new short-lived certificate, or when the SVID naturally expires), the signing system:\n\n- Detects that the current certificate differs from the last entry in the certificate table.\n- Appends a new certificate entry to the table.\n- New signatures reference the new certificate index.\n\nPrevious signatures remain valid because they reference their original certificate, which is still in the table and verifiable against the CA trust anchors.\n\nBoth Cert A and Cert B chain to the same Sentry CA, so all signatures remain valid.\n\nIn multi-replica deployments, each replica has its own private key and SVID certificate. When the workflow runtime migrates between replicas (for example, due to scaling or rebalancing), the new replica’s certificate is appended to the table. All certificates are validated as belonging to the same app ID via SPIFFE identity binding.\n\n#### Important\n\n**Certificate rotation** (new leaf SVID, same CA root) works seamlessly.\n\nA full **CA rotation** (completely different root CA) will cause verification\nto fail for workflows signed under the old CA, because the old signing\ncertificates will not chain to the new trust anchors. This is by design: if\nthe trust root changes, previously signed data cannot be verified.\n\n### Long-running workflows and root CA expiry\n\nThis is the operational consideration that has the biggest practical impact on signed workflows, and is worth calling out separately.\n\nBy default Dapr generates a self-signed root CA that is valid for **one year**.\nIf that root expires (or is replaced with a CA built from a different private\nkey), any workflow whose signature chain trusts the old root **stops being\nverifiable**. Signed long-running workflows that outlive a CA rotation will\nfail to load and surface as `FAILED`\n\nwith `SignatureVerificationFailed`\n\n.\n\nTo keep long-running signed workflows healthy across CA renewals, do **one**\nof the following:\n\n**Rotate the leaf/issuer cert, keep the same root key.** This is the recommended path. The Dapr CLI supports it with`dapr mtls renew-certificate -k --private-key <existing-root-key>`\n\n(see[renew certificates](https://v1-18.docs.dapr.io/operations/security/mtls/#root-and-issuer-certificate-upgrade-using-cli-recommended)). Existing signed workflows continue to verify against the same root.**Bring your own CA.** Provide a root certificate whose private key you control and store securely (for example, in your own HSM or secret store). Renew the leaf/issuer with the same root key indefinitely. See[bringing your own certificates](https://v1-18.docs.dapr.io/operations/security/mtls/#bringing-your-own-certificates).**Drain before rotating to a new root.** If you must rotate to a brand new root key, complete or purge in-flight signed workflows first. Signing is a one-way commitment, so there is no “re-sign with the new root” operation.\n\n#### Plan your CA lifecycle before enabling signing\n\nIf your workflows can run for weeks or months,**back up your root private key** and treat root CA rotation as a planned, drain-and-rotate event. Losing the root key or rotating to a freshly generated root will permanently break verification for any signed workflow that was issued under the old root.\n\n## How signing works\n\n### Signing new events\n\nAfter each workflow execution step, Dapr signs the newly appended history events.\n\nThe signing process works as follows:\n\n**Deterministic marshaling**: Each new`HistoryEvent`\n\nis marshaled using protobuf’s deterministic mode, producing stable bytes for the same message. These exact bytes are both signed and persisted to the state store.**Events digest**: A SHA-256 hash is computed over the batch of marshaled events, with each event length-prefixed (big-endian uint64) to prevent concatenation ambiguity.**Chain linkage**: The SHA-256 digest of the previous`HistorySignature`\n\nprotobuf message is computed. The root signature (first in the chain) has no previous digest.**Signature input**: The final signing input is`SHA-256(previousSignatureDigest || eventsDigest)`\n\n.**Cryptographic signing**: The input is signed using the sidecar’s SPIFFE X.509 private key. The signing key type is whatever Sentry issues to the sidecar; Dapr supports Ed25519, ECDSA P-256, and RSA. You don’t choose the key type per workflow: it follows from the[mTLS](https://v1-18.docs.dapr.io/operations/security/mtls/)setup (Dapr-generated keys default to Ed25519 from 1.18 onwards, custom CAs sign with whatever algorithm the issuer key uses).**Certificate resolution**: If the current SVID certificate matches the last entry in the certificate table, the existing index is reused. Otherwise, a new entry is appended. This handles[certificate rotation](/developing-applications/building-blocks/workflow/workflow-history-signing/#certificate-rotation)transparently.**Persistence**: The signature, any new certificate entry, and the history events are all persisted to the state store in a single transactional write, ensuring atomicity.\n\n### Verification on load\n\nEvery time workflow state is loaded (whether for execution or a metadata query) the full signature chain is verified.\n\nThe verification steps for each signature in the chain are:\n\n| Step | Check | Detects |\n|---|---|---|\n| Chain linkage | `previousSignatureDigest` matches `SHA-256(previous signature)` | Reordered or inserted signatures |\n| Contiguity | Event ranges are adjacent with no gaps | Missing signatures |\n| Events digest | Recompute SHA-256 from raw stored bytes | Tampered, inserted, or deleted events |\n| Cryptographic signature | Verify against public key from the signing certificate | Forged signatures |\n| Certificate validity | Certificate was valid at the time of the last signed event | Expired or backdated certificates |\n| Chain-of-trust | Certificate chains to a trusted Sentry CA root | Signing by untrusted identity |\n| App identity | SPIFFE ID in certificate matches the workflow’s owning app | Cross-app signature forgery |\n| Full coverage | Signatures cover every event from index 0 to the end | Partially unsigned history |\n\nVerification uses the **raw bytes from the state store**, not re-marshaled\nevents. This ensures that any byte-level modification to persisted events is\ndetected.\n\n### Inbox event validation\n\nWhen signing is enabled, the Dapr workflow runtime validates inbox events before\nprocessing them. Result events (`TaskCompleted`\n\n, `TaskFailed`\n\n,\n`ChildWorkflowInstanceCompleted`\n\n, `ChildWorkflowInstanceFailed`\n\n) must reference\nan operation that was actually scheduled in the signed history. Events that\nreference non-existent operations, such as a `TaskCompleted`\n\nfor a task ID\nthat was never scheduled, are considered injected and are purged from the\ninbox. This prevents an attacker with state store access from injecting fake\nactivity or child workflow results that would otherwise be signed into the\nhistory chain.\n\n### Child workflow and activity attestation\n\nThe same-workflow signature chain only protects history that the workflow itself produced. Cross-identity completion events (a child workflow or activity running under a different SPIFFE identity reporting back to the parent) need their own cryptographic proof. Dapr emits an attestation on every cross-identity completion and verifies it before the event enters the parent’s inbox.\n\nWhen a child workflow or activity completes, the executor attaches one of:\n\n`ChildCompletionAttestation`\n\n(on`ChildWorkflowInstanceCompleted`\n\n/`ChildWorkflowInstanceFailed`\n\n).`ActivityCompletionAttestation`\n\n(on`TaskCompleted`\n\n/`TaskFailed`\n\n).\n\nEach attestation commits to a deterministic, language-independent payload:\n\n| Field | Description |\n|---|---|\n`parentInstanceId` + `parentTaskScheduledId` | Bind the proof to a specific invocation. Prevents cross-instance and cross-task replay. |\n`ioDigest` | SHA-256 over canonicalized, NFC-normalized input and output bytes. Identical across SDKs because the encoding is wire-format-independent and spec-versioned. |\n`signerCertDigest` | SHA-256 of the signer’s DER-encoded X.509 chain. The chain itself travels alongside the attestation as a wire-only companion field. |\n`terminalStatus` | The workflow or activity outcome. For activities, the activity name is also committed. |\n\nOn receipt, the parent’s Dapr workflow runtime runs `verifyInboxAttestation`\n\nbefore the\nevent is appended to the inbox:\n\n- The attestation must be present (signing on implies sender must attest).\n- The companion certificate’s digest must match the committed\n`signerCertDigest`\n\n. - The certificate chain must validate against a Sentry trust anchor.\n- The signature must verify over the exact attestation payload bytes (no re-marshaling on the receiver side).\n`parentInstanceId`\n\nmust equal the receiving workflow’s actor ID.`parentTaskScheduledId`\n\nmust resolve to a`TaskScheduled`\n\nor`ChildWorkflowInstanceCreated`\n\nevent already present in the signed history.`ioDigest`\n\nmust equal the canonical digest of the parent’s scheduling input together with the reported output.`terminalStatus`\n\nmust match the enclosing event type.\n\nIf any check fails, the inbound completion is rejected and the workflow is\ntombstoned into failure. Once verified, the foreign certificate is absorbed\ninto a content-addressed `ext-sigcert-NNNNNN`\n\ntable on the receiver so the same\nsigner is not re-validated on every subsequent completion. The chain-of-trust\nresult is also cached per workflow instance for the lifetime of that instance,\nso a workflow that calls the same foreign signer repeatedly pays chain\nvalidation cost only once.\n\n### Lineage propagation verification\n\nWhen a workflow’s history is forwarded to another workflow as\n`IncomingHistory`\n\nunder `PropagateLineage`\n\n(typically multi-hop child\ninvocations across apps), the receiving workflow needs to verify the forwarded\ncontent. Because the propagated bytes do not become part of the receiver’s own\nsigned `History`\n\n, an independent per-chunk signature is attached at dispatch\ntime.\n\nEach `PropagatedHistoryChunk`\n\ncarries:\n\n- The original events as produced by the chunk’s authoring app, byte-for-byte.\n- A fresh chunk-local signature over those events, produced by the chunk’s app using its current SPIFFE X.509 key.\n- The DER-encoded certificate chain for that signing key.\n\nOn ingestion, every chunk is verified independently:\n\n| Check | Detects |\n|---|---|\n| Chain-of-trust to a Sentry trust anchor (reusing the per-instance cache) | Forged or self-signed lineage |\nLeaf SPIFFE ID’s app component matches the chunk’s declared `appId` | Lineage produced by the wrong app |\n| Per-chunk signature covers exactly the events in the chunk, contiguously | Splicing, gaps, or partial omission |\n| Empty-with-signatures, missing-signatures, missing-certificate variants | Malformed chunks that would otherwise short-circuit verification |\n\nVerified foreign certificates are absorbed into the same `ext-sigcert-NNNNNN`\n\ntable used by completion attestations, so downstream attestation lookups can\ncontent-address them.\n\n## What happens when verification fails\n\nWhen signature verification fails, Dapr’s response depends on whether the\nworkflow is still in custody of the executor, and on whether the failure is a\ngenuine tamper or a host configuration mismatch. In every case the original\nhistory, signatures, and inbox are **never modified**; the untrusted data is\npreserved for forensic analysis.\n\n### Tamper of an in-flight workflow\n\nWhen the Dapr workflow runtime loads a running workflow and detects tampering,\nDapr stops the executor from acting on forged input by appending a single\n**unsigned terminal ExecutionCompleted event** marked with the well-known\nerror type\n\n`DAPR_WORKFLOW_HISTORY_TAMPERED`\n\n. This:- Halts further execution: the workflow is now in a terminal state and the Dapr workflow runtime will not replay or schedule new work on it.\n- Provides a stable, machine-matchable signal for clients\n(\n`DAPR_WORKFLOW_HISTORY_TAMPERED`\n\n). - Preserves the original (untrusted) history and signatures so operators can inspect what was tampered with.\n\nFrom an application perspective the workflow surfaces as `FAILED`\n\n, and any\nclient that calls `GetWorkflow`\n\n(or the equivalent SDK method) on the instance\nreceives the failure with the `DAPR_WORKFLOW_HISTORY_TAMPERED`\n\nerror type in\nthe failure details. SDKs raise this as the same exception type that a normal\nworkflow failure would raise, so existing error-handling paths can match on\nthe error type to specifically detect tampering. The Dapr sidecar also emits\nan error-level log entry naming the workflow instance ID and the specific\nverification check that failed.\n\n`LoadWorkflowState`\n\nrecognises the tamper marker and bypasses signature\nverification on subsequent loads, so the workflow can be read back and surfaced\nas `FAILED`\n\nrather than failing to load entirely. Reminders for the workflow\nand its activities are deleted to stop the engine from endlessly retrying a\ncompromised workflow.\n\nThe tamper-recovery path is scoped to workflows still in custody of the\nexecutor. **Workflows that had already completed** before the tamper happened\nare left untouched: there is nothing for the executor to do, and the\nverification error is surfaced to readers (metadata queries) who are\nresponsible for detecting the tampering at read time.\n\n### Configuration error\n\nA mismatch between signed history and host signing configuration (signed\nworkflow loaded by a host without a signer, or unsigned workflow loaded by a\nsigning-enabled host) is reported as a distinct `ConfigurationError`\n\n, not as\ntampering. The tamper-recovery path described above does not run against these\nintact-but-unloadable workflows: their history is byte-identical to what was\nwritten, so appending a tamper-terminal event would itself be a destructive\nedit. Fix the host configuration, or purge the workflow, to recover.\n\nThe error is visible in two places: the Dapr sidecar logs an error-level entry\non the failed load (naming the workflow instance ID and whether the host was\nexpected to have or not have a signer), and callers that issue a workflow\nmetadata query (`GET /v1.0/workflows/<id>`\n\nor the SDK equivalent) receive the\n`ConfigurationError`\n\ndirectly in the response, allowing programmatic detection.\n\n### Metadata queries (API path)\n\nWhen a workflow metadata query (such as `GET /v1.0/workflows/<id>`\n\nor\n`FetchWorkflowMetadata`\n\n) encounters a verification error, the error is returned\ndirectly to the caller. The error message contains the specific reason for\nfailure (for example, digest mismatch, attestation verification failure, or\ncertificate trust failure).\n\n### Common failure causes\n\n| Cause | What happened | Detection |\n|---|---|---|\n| Tampered history | A history event was modified directly in the state store | Events digest mismatch |\n| Deleted event | A history event was removed from the state store | Event count or coverage mismatch |\n| Inserted event | An event was added outside of normal workflow execution | Events digest mismatch |\n| Reordered events | Events were rearranged in the state store | Events digest mismatch |\n| Injected inbox event | A fake result was written to the inbox in the state store | Inbox validation: no matching scheduled operation |\n| CA change | Sentry CA was rotated to a completely new root | Certificate chain-of-trust failure |\n| Cross-app forgery | A certificate from a different app was used to sign | SPIFFE app identity mismatch |\n| Corrupted signature | A signature entry was modified in the state store | Cryptographic signature verification failure or chain linkage mismatch |\n| Forged child or activity completion | A cross-identity completion event was injected without a valid attestation | Inbox attestation verification fails (missing attestation, bad signature, wrong parent ID or task scheduled ID, ioDigest mismatch) |\n| Tampered propagated lineage | A `PropagatedHistoryChunk` was modified, swapped, or signed by the wrong app | Per-chunk signature, chain-of-trust, or SPIFFE app-ID check fails |\n| Signing disabled (ConfigurationError) | A signed workflow was loaded by a non-signing host | “signed history but no signer is configured” |\n| Signing enabled on unsigned (ConfigurationError) | An unsigned workflow was loaded by a signing host | “unsigned history events but signing is enabled” |\n\n## State store layout\n\nWorkflow signing data is stored alongside the workflow state using the following key prefixes. All keys are scoped to the workflow instance’s ID.\n\n| Key pattern | Content | Format |\n|---|---|---|\n`history-NNNNNN` | History events | Protobuf `HistoryEvent` |\n`signature-NNNNNN` | Signature entries | Protobuf `HistorySignature` |\n`sigcert-NNNNNN` | Signing certificates (own SPIFFE identity) | Protobuf `SigningCertificate` (DER-encoded X.509 chain) |\n`ext-sigcert-NNNNNN` | Foreign signing certificates absorbed from verified completion attestations and lineage chunks (deduped by digest) | Protobuf `SigningCertificate` (DER-encoded X.509 chain) |\n`metadata` | Counts and generation | Protobuf `WorkflowStateMetadata` |\n\nThe `NNNNNN`\n\nsuffix is a zero-padded 6-digit index (for example, `signature-000000`\n\n,\n`signature-000001`\n\n).\n\nThe `metadata`\n\nentry tracks the count of each entry type so the loader knows\nexactly how many keys to fetch. All writes (history events, signatures,\ncertificates, metadata) are persisted in a single transactional state\noperation, ensuring atomicity.\n\n## Security properties\n\n| Property | Guarantee |\n|---|---|\nTamper detection | Any modification to persisted history events changes the events digest, breaking verification |\nChain integrity | The `previousSignatureDigest` linkage prevents reordering, inserting, or removing signatures |\nNon-repudiation | Each signature is bound to a specific X.509 identity (SPIFFE SVID) |\nApp identity binding | The SPIFFE ID in each signing certificate is validated against the workflow’s owning app ID, preventing cross-app forgery |\nTime binding | Certificate validity is checked against the event timestamp, preventing use of expired credentials |\nTrust anchoring | All signing certificates are verified against the Sentry CA trust bundle |\nInbox validation | Activity and child workflow results are validated against scheduled operations in signed history, preventing injection of fake results |\nCross-identity attestation | Child workflow and activity completions are cryptographically attested by the executing identity and verified by the parent before they enter the inbox |\nLineage integrity | Forwarded `PropagatedHistoryChunk` s are individually signed and verified against the producing app’s SPIFFE identity |\nImmutable history | Dapr never modifies the existing workflow history, signatures, or inbox; the only write on tamper detection is an unsigned terminal `ExecutionCompleted` event with error type `DAPR_WORKFLOW_HISTORY_TAMPERED` |\nOne-way commitment | Signing cannot be disabled for signed workflows or enabled for unsigned workflows |\n\n## Frequently asked questions\n\n### Does signing add latency to workflow execution?\n\nThe signing operation itself (SHA-256 hashing and ECDSA/Ed25519/RSA signing) is fast and adds negligible CPU latency. The measurable cost is on the state store side: each workflow step now persists additional entries (a signature entry, and on certificate rotation a new certificate entry) alongside the history events. These extra entries are written in the same transactional batch as the history events, so they cost one larger transaction rather than additional round-trips, but they do increase the payload size and the storage footprint per workflow. The exact impact depends on your state store’s pricing and write characteristics.\n\n### What happens if I disable signing on a workflow that was previously signed?\n\nThe workflow **fails to load**. Signing is a [one-way commitment](/developing-applications/building-blocks/workflow/workflow-history-signing/#one-way-commitment):\nonce a workflow has signed history, it must always run on a signing-enabled\nhost. This prevents an attacker from disabling signing to bypass verification.\n\n### Can I enable signing on workflows that were created without it?\n\n**No.** Enabling signing on a host that loads unsigned workflow history causes a\nverification error. The unsigned history has no integrity proof and cannot be\nretroactively signed, because events written without signing could have been\ntampered with. Ensure all unsigned workflows complete or are purged before\nenabling signing cluster-wide.\n\n### What happens during a Sentry CA rotation?\n\n**Certificate rotation** (new leaf SVID, same CA root): works seamlessly.\nMultiple certificates are stored in the certificate table and each signature\nreferences its specific certificate. All certificates chain to the same CA.\n\n**CA rotation** (completely new root CA): verification fails for workflows\nwhose signing certificates were issued by the old CA. The workflow is\nreported as FAILED with `SignatureVerificationFailed`\n\n. This is intentional:\nthe trust root has changed and previously signed data cannot be verified\nagainst the new trust anchors. See [long-running workflows and root CA\nexpiry](/developing-applications/building-blocks/workflow/workflow-history-signing/#long-running-workflows-and-root-ca-expiry) for the operational\nguidance to avoid this.\n\n### What about multi-replica deployments?\n\nEach replica of the same app ID has its own private key and SVID certificate. When the workflow runtime migrates between replicas, each replica’s certificate is stored in the certificate table and the signature chain remains valid. All certificates are verified as belonging to the same app ID via SPIFFE identity binding.\n\n### What state store backends are supported?\n\nHistory signing works with any state store that supports the actor state transactional API. The signing data is stored as additional key-value entries alongside the existing workflow state.", "url": "https://wpnews.pro/news/we-built-hash-chained-workflow-histories-to-make-agent-execution-tamper-evident", "canonical_source": "https://v1-18.docs.dapr.io/developing-applications/building-blocks/workflow/workflow-history-signing/", "published_at": "2026-06-19 14:55:15+00:00", "updated_at": "2026-06-19 15:08:14.269856+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "ai-infrastructure"], "entities": ["Dapr", "SPIFFE", "Sentry", "X.509"], "alternates": {"html": "https://wpnews.pro/news/we-built-hash-chained-workflow-histories-to-make-agent-execution-tamper-evident", "markdown": "https://wpnews.pro/news/we-built-hash-chained-workflow-histories-to-make-agent-execution-tamper-evident.md", "text": "https://wpnews.pro/news/we-built-hash-chained-workflow-histories-to-make-agent-execution-tamper-evident.txt", "jsonld": "https://wpnews.pro/news/we-built-hash-chained-workflow-histories-to-make-agent-execution-tamper-evident.jsonld"}}