AUTH4D-279 Signing secret overlap for flows and webhooks

Rotating the project’s flows signing secret (AUTH4D-160) or a webhook endpoint’s signing secret (AUTH4D-157) used to replace it at once, so every Call your backend step and every webhook delivery failed verification until the customer deployed the new secret. Now the replaced secret keeps signing for 24 hours, the same overlap client secrets have had since AUTH4D-207.

While the previous secret is unexpired, each outgoing request carries one signature per secret in a single Standard Webhooks header, new first:

webhook-signature: v1,<signature with the new secret> v1,<signature with the old secret>

Afterwards only v1,<new> is sent. The server SDK’s verifyFlowStepSignature and verifyFlowStepRequest, Svix and other Standard Webhooks libraries already accept a request when any listed signature matches, so no SDK change is needed. Queued webhook retries are signed when they are attempted, so they follow the same rule.

Rules

  • A rotation moves the current encrypted secret into the previous slot with an expiry of now plus 86,400 seconds (FLOW_SIGNING_SECRET_OVERLAP_SECONDS, WEBHOOK_SECRET_OVERLAP_SECONDS). The move, the new secret and the audit row are written in one D1 batch.
  • Only one previous secret is kept: rotating again drops the older one at once.
  • Creating the first flows secret, or a new endpoint, starts no overlap.
  • Ending the overlap removes the previous secret at once. With no previous secret it changes nothing, writes no audit row and still answers 200.
  • The previous secret is never returned. Responses only carry previousExpiresAt, an ISO 8601 time, or null when there is no previous secret or its overlap has run out.
  • A previous secret that cannot be decrypted is left out; the current secret still signs. A current secret that cannot be decrypted fails the step or delivery as before.

Routes

Method and path Scope / role Result
GET /management/tenants/{tenantId}/flow-signing-secret tenant:read, any role { configured, createdAt, previousExpiresAt }
POST /management/tenants/{tenantId}/flow-signing-secret tenant:write, owner or admin, {} { secret, createdAt, previousExpiresAt }
DELETE /management/tenants/{tenantId}/flow-signing-secret/previous tenant:write, owner or admin, {} { configured, createdAt, previousExpiresAt }
GET /management/tenants/{tenantId}/webhooks tenant:read, any role each endpoint has previousExpiresAt
POST /management/tenants/{tenantId}/webhooks/{endpointId}/rotate-secret tenant:write, owner or admin, {} { endpoint, signingSecret }
DELETE /management/tenants/{tenantId}/webhooks/{endpointId}/previous-secret tenant:write, owner or admin, {} { endpoint }, with previousExpiresAt: null

Both DELETE routes take an empty JSON object body (400 invalid_request otherwise). Viewers get 403 insufficient_role, and the refusal is audited with outcome denied. Every query is filtered by the path’s tenantId; a member of another project gets 403, and an endpoint of another project is 404 not_found. Every WebhookEndpoint (list, create, update, delete, rotate) now has previousExpiresAt.

Contracts (packages/contracts, additive): previousExpiresAt on FlowSigningSecretStatusResponseSchema, FlowSigningSecretResponseSchema and WebhookEndpointSchema, plus the two overlap constants.

Audit events

Event Target type Metadata
management.flow.signing_secret_rotated flow_signing_secret overlap_seconds on a replace
management.flow.signing_secret_overlap_ended flow_signing_secret actor_role
management.webhook.secret_rotated webhook_endpoint overlap_seconds
management.webhook.secret_overlap_ended webhook_endpoint actor_role

overlap_seconds is 86,400, or 0 when the database does not have migration 0039 yet. It was already in the SAFE_METADATA allowlist (AUTH4D-207), so no new metadata keys are added. Secrets and envelopes never enter the audit log.

Storage

0039_signing_secret_overlap.sql adds previous_encrypted_secret and previous_secret_expires_at to flow_signing_secrets and webhook_endpoints, with a CHECK that keeps the expiry with its envelope. The tables keep their tenant_id keys, and the previous envelope is sealed for the same tenant and purpose as the current one. Code reads a missing column (no such column) as “no previous secret”: before the migration, rotation replaces the secret at once, previousExpiresAt is always null and ending the overlap is a no-op. See d1-schema.md (repository file docs/operations/d1-schema.md).

Console

Flows signing secret (on the flows storyboard) and each webhook endpoint show “Old secret works until time” while an overlap lasts. Owners and admins get End now, which asks for confirmation. The rotation dialogs say that both secrets sign for the next 24 hours, and the one-time secret dialog says until when. The console BFF forwards the two new DELETE paths.

Tests

  • tests/integration/auth4d-279.signing-secret-overlap.test.ts: with a fake clock, the server SDK’s flows verifier and the Svix verifier accept the old and new secrets during the window and only the new one afterwards; double rotation keeps only the latest previous secret; ending early, role checks, audit events and tenant isolation; and a database without 0039 still rotating at once.
  • tests/e2e/auth4d-279.signing-secret-overlap.spec.ts (auth4d-279.playwright.config.ts, port 5429): the overlap line and End now in both console sections, by keyboard, with axe checks at 320px and 1280px, and a viewer without the action.

Source: docs/api-spec/auth4d-279.md