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, ornullwhen 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