Skip to content

Commit 08d1ddb

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(webapp,webhook-engine): keep the old webhook signing secret valid during rotation
Mono-RevId: 9d6143af78b860388fa25f05dc471524e84d0ec8
1 parent 0e9937c commit 08d1ddb

19 files changed

Lines changed: 476 additions & 58 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
HMAC webhook verifier configs accept `additionalSignatureHeaders`: more headers that can carry a signature, matched as alternatives to `signatureHeader`. Providers that send a second signature during a secret rotation (Hookdeck's `x-hookdeck-signature-2`) keep verifying through the rotation.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Rotating a webhook endpoint's signing secret can keep the old one valid for a while, so you can rotate without failing deliveries that are still signed with it. The rotate and regenerate dialogs let you choose how long the old secret stays valid (up to 7 days, 24 hours by default). The `rotate-secret` and `secret` endpoints accept an optional `previousSecretTtlSeconds` and return `previousSecretExpiresAt`. With no value, the old secret stops working immediately, as before.

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam/route.tsx‎

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { Label } from "~/components/primitives/Label";
2929
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
3030
import { Paragraph } from "~/components/primitives/Paragraph";
3131
import * as Property from "~/components/primitives/PropertyTable";
32+
import { Select, SelectItem } from "~/components/primitives/Select";
3233
import {
3334
ResizableHandle,
3435
ResizablePanel,
@@ -68,6 +69,7 @@ import { docsPath, EnvironmentParamSchema, v3WebhooksPath } from "~/utils/pathBu
6869
import { throwPermissionDenied } from "~/utils/permissionDenied";
6970
import { parseFiniteInt } from "~/utils/searchParams";
7071
import { webhookEngine, webhookVerifyTokenKey } from "~/v3/webhookEngine.server";
72+
import { PreviousSecretTtlSeconds } from "~/v3/webhookSigningSecret";
7173
import {
7274
generateWebhookSigningSecret,
7375
storeWebhookSigningSecret,
@@ -209,7 +211,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
209211
});
210212
};
211213

212-
const SetSecretSchema = z.object({
214+
const PreviousSecretTtlFormField = z.object({
215+
previousSecretTtlSeconds: z.coerce.number().pipe(PreviousSecretTtlSeconds).optional(),
216+
});
217+
218+
const SetSecretSchema = PreviousSecretTtlFormField.extend({
213219
intent: z.literal("set-secret"),
214220
secret: z.string().trim().min(1, "A signing secret is required"),
215221
});
@@ -261,9 +267,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
261267

262268
// Generate (integrator-supplied secret): the UI reveals it ONCE for pasting into the provider.
263269
if (intent === "generate-secret") {
264-
const result = await generateWebhookSigningSecret(endpoint);
270+
const ttl = PreviousSecretTtlFormField.safeParse(Object.fromEntries(formData));
271+
if (!ttl.success) {
272+
return { success: false as const, error: ttl.error.issues[0]?.message ?? "Invalid" };
273+
}
274+
const result = await generateWebhookSigningSecret(endpoint, ttl.data);
265275
return result.ok
266-
? { success: true as const, generatedSecret: result.secret }
276+
? {
277+
success: true as const,
278+
generatedSecret: result.secret,
279+
previousSecretExpiresAt: result.previousSecretExpiresAt,
280+
}
267281
: { success: false as const, error: result.error };
268282
}
269283

@@ -287,7 +301,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
287301
if (!submission.success) {
288302
return { success: false as const, error: submission.error.issues[0]?.message ?? "Invalid" };
289303
}
290-
await storeWebhookSigningSecret(endpoint, submission.data.secret);
304+
await storeWebhookSigningSecret(endpoint, submission.data.secret, {
305+
previousSecretTtlSeconds: submission.data.previousSecretTtlSeconds,
306+
});
291307

292308
return { success: true as const };
293309
};
@@ -683,7 +699,12 @@ function ProviderSetup({ verifier, source }: { verifier: LoaderData["verifier"];
683699
<Property.Item>
684700
<Property.Label>Signature header</Property.Label>
685701
<Property.Value>
686-
<span className="font-mono text-sm">{config.signatureHeader}</span>
702+
<span className="font-mono text-sm">
703+
{[
704+
config.signatureHeader,
705+
...(config.scheme === "hmac" ? (config.additionalSignatureHeaders ?? []) : []),
706+
].join(", ")}
707+
</span>
687708
</Property.Value>
688709
</Property.Item>
689710
<Property.Item>
@@ -780,6 +801,39 @@ function describeTimestampSource(source: WebhookValueSource): string {
780801
}
781802
}
782803

804+
const PREVIOUS_SECRET_TTL_OPTIONS = [
805+
{ value: "0", label: "Stop immediately" },
806+
{ value: "3600", label: "1 hour" },
807+
{ value: "86400", label: "24 hours" },
808+
{ value: "604800", label: "7 days" },
809+
];
810+
811+
function PreviousSecretTtlField({ credentialNoun }: { credentialNoun: string }) {
812+
return (
813+
<div className="flex flex-col gap-1">
814+
<Label>Keep accepting the old {credentialNoun} for</Label>
815+
<Select
816+
name="previousSecretTtlSeconds"
817+
variant="tertiary/medium"
818+
dropdownIcon
819+
items={PREVIOUS_SECRET_TTL_OPTIONS}
820+
defaultValue="86400"
821+
text={(value) => PREVIOUS_SECRET_TTL_OPTIONS.find((o) => o.value === value)?.label}
822+
>
823+
{PREVIOUS_SECRET_TTL_OPTIONS.map((option) => (
824+
<SelectItem key={option.value} value={option.value}>
825+
{option.label}
826+
</SelectItem>
827+
))}
828+
</Select>
829+
<Hint>
830+
Deliveries signed with the old {credentialNoun} still verify until then, so you can update
831+
the provider without failing any. Stop immediately if the old one leaked.
832+
</Hint>
833+
</div>
834+
);
835+
}
836+
783837
function SetSecretDialog({
784838
hasSigningSecret,
785839
credentialNoun,
@@ -832,6 +886,7 @@ function SetSecretDialog({
832886
: "Stored encrypted and never shown again. Deliveries are verified against this secret."}
833887
</Hint>
834888
</div>
889+
{hasSigningSecret ? <PreviousSecretTtlField credentialNoun={credentialNoun} /> : null}
835890
{fetcher.data && !fetcher.data.success ? (
836891
<Paragraph variant="small" className="text-error">
837892
{fetcher.data.error}
@@ -864,6 +919,10 @@ function GenerateSecretDialog({ hasSigningSecret }: { hasSigningSecret: boolean
864919
const isSubmitting = fetcher.state !== "idle";
865920
const generated =
866921
fetcher.data && "generatedSecret" in fetcher.data ? fetcher.data.generatedSecret : undefined;
922+
const previousSecretExpiresAt =
923+
fetcher.data && "previousSecretExpiresAt" in fetcher.data
924+
? fetcher.data.previousSecretExpiresAt
925+
: undefined;
867926

868927
return (
869928
<Dialog open={open} onOpenChange={setOpen}>
@@ -883,6 +942,12 @@ function GenerateSecretDialog({ hasSigningSecret }: { hasSigningSecret: boolean
883942
</Paragraph>
884943
<ClipboardField value={generated} variant="secondary/medium" />
885944
<Hint>Paste this into your provider's webhook signing-secret field.</Hint>
945+
{previousSecretExpiresAt ? (
946+
<Hint>
947+
The previous secret keeps verifying deliveries until{" "}
948+
<DateTime date={previousSecretExpiresAt} />.
949+
</Hint>
950+
) : null}
886951
<div className="flex justify-end">
887952
<Button type="button" variant="primary/small" onClick={() => setOpen(false)}>
888953
Done
@@ -896,6 +961,12 @@ function GenerateSecretDialog({ hasSigningSecret }: { hasSigningSecret: boolean
896961
Trigger.dev generates a strong signing secret, stores it encrypted, and shows it once
897962
so you can paste it into your provider.
898963
</Paragraph>
964+
{hasSigningSecret ? <PreviousSecretTtlField credentialNoun="signing secret" /> : null}
965+
{fetcher.data && !fetcher.data.success ? (
966+
<Paragraph variant="small" className="text-error">
967+
{fetcher.data.error}
968+
</Paragraph>
969+
) : null}
899970
<div className="flex justify-end gap-2">
900971
<Button
901972
type="button"

‎apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.rotate-secret.ts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@ import { z } from "zod";
33
import { webhookPrisma } from "~/db.server";
44
import { webhookEndpointLookup } from "~/v3/webhookEndpointLookup";
55
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
6+
import { safeJsonParse } from "~/utils/json";
7+
import { PreviousSecretTtlSeconds } from "~/v3/webhookSigningSecret";
68
import { generateWebhookSigningSecret } from "~/v3/webhookSigningSecret.server";
79

810
const ParamsSchema = z.object({ endpointId: z.string() });
911

12+
/** Optional: existing callers POST with no body, which keeps the replace-immediately behavior. */
13+
const RotateSecretRequestBody = z.object({
14+
previousSecretTtlSeconds: PreviousSecretTtlSeconds.optional(),
15+
});
16+
1017
// POST /api/v1/webhooks/endpoints/:endpointId/rotate-secret — mint a new signing secret and return
1118
// it ONCE. Only for schemes we generate (hmac / shared-secret); asymmetric endpoints set a public key.
1219
const { action, loader } = createActionApiRoute(
@@ -17,17 +24,28 @@ const { action, loader } = createActionApiRoute(
1724
corsStrategy: "all",
1825
authorization: { action: "write", resource: () => ({ type: "webhooks" }) },
1926
},
20-
async ({ params, authentication }) => {
27+
async ({ params, authentication, request }) => {
28+
const rawBody = await request.text();
29+
const body = RotateSecretRequestBody.safeParse(rawBody ? safeJsonParse(rawBody) : {});
30+
if (!body.success) {
31+
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
32+
}
33+
2134
const endpoint = await webhookPrisma.webhookEndpoint.findFirst({
2235
where: webhookEndpointLookup(authentication.environment.id, params.endpointId),
2336
select: { id: true, friendlyId: true, verifierArtifact: true },
2437
});
2538
if (!endpoint) return json({ error: "Not found" }, { status: 404 });
2639

27-
const result = await generateWebhookSigningSecret(endpoint);
40+
const result = await generateWebhookSigningSecret(endpoint, body.data);
2841
if (!result.ok) return json({ error: result.error }, { status: 400 });
2942

30-
return json({ id: endpoint.friendlyId, secretSet: true as const, secret: result.secret });
43+
return json({
44+
id: endpoint.friendlyId,
45+
secretSet: true as const,
46+
secret: result.secret,
47+
previousSecretExpiresAt: result.previousSecretExpiresAt,
48+
});
3149
}
3250
);
3351

‎apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.secret.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,19 @@ import { z } from "zod";
44
import { webhookPrisma } from "~/db.server";
55
import { webhookEndpointLookup } from "~/v3/webhookEndpointLookup";
66
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
7+
import { PreviousSecretTtlSeconds } from "~/v3/webhookSigningSecret";
78
import { storeWebhookSigningSecret } from "~/v3/webhookSigningSecret.server";
89

910
const ParamsSchema = z.object({ endpointId: z.string() });
1011

12+
const SetSecretRequestBody = SetWebhookEndpointSecretRequestBody.extend({
13+
previousSecretTtlSeconds: PreviousSecretTtlSeconds.optional(),
14+
});
15+
1116
const { action, loader } = createActionApiRoute(
1217
{
1318
params: ParamsSchema,
14-
body: SetWebhookEndpointSecretRequestBody,
19+
body: SetSecretRequestBody,
1520
method: "PUT",
1621
allowJWT: true,
1722
corsStrategy: "all",
@@ -24,9 +29,11 @@ const { action, loader } = createActionApiRoute(
2429
});
2530
if (!endpoint) return json({ error: "Not found" }, { status: 404 });
2631

27-
await storeWebhookSigningSecret(endpoint, body.secret);
32+
const { previousSecretExpiresAt } = await storeWebhookSigningSecret(endpoint, body.secret, {
33+
previousSecretTtlSeconds: body.previousSecretTtlSeconds,
34+
});
2835

29-
return json({ id: endpoint.friendlyId, secretSet: true as const });
36+
return json({ id: endpoint.friendlyId, secretSet: true as const, previousSecretExpiresAt });
3037
}
3138
);
3239

‎apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam.replay-source.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ function signatureHeaderNames(artifact: unknown): Set<string> {
5757
const config = parsed.data.config;
5858
if (config.scheme === "hmac" || config.scheme === "asymmetric") {
5959
names.add(config.signatureHeader.toLowerCase());
60+
if (config.scheme === "hmac") {
61+
for (const header of config.additionalSignatureHeaders ?? []) names.add(header.toLowerCase());
62+
}
6063
if (config.timestamp?.source.from === "header") {
6164
const { name, fallback } = config.timestamp.source;
6265
for (const header of [name, ...(fallback ?? [])]) names.add(header.toLowerCase());

‎apps/webapp/app/v3/webhookEngine.server.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { engine as runEngine } from "./runEngine.server";
2121
import { ServiceValidationError } from "./services/common.server";
2222
import { TriggerTaskService } from "./services/triggerTask.server";
2323
import { resolveWebhookSession } from "./webhookSessionTarget.server";
24+
import { activeSigningSecrets, StoredSigningSecret } from "./webhookSigningSecret";
2425
import { webhookLimitsForEnvironment } from "./webhookLimits.server";
2526
import { webhookPortErrorMessage } from "./webhookPortErrors.server";
2627
import { webhookWaitpoints } from "./webhookWaitpoints.server";
@@ -77,10 +78,6 @@ function webhookRedisConnection(): WebhookRedisOptions {
7778
};
7879
}
7980

80-
// The plaintext signing secret is stored under the "DATABASE" SecretStore
81-
// provider as { secret: string } (same shape as environment variables).
82-
const SigningSecretSchema = z.object({ secret: z.string() });
83-
8481
/** The GET verification token (Meta hub.verify_token) an endpoint owner generated in the dashboard. */
8582
const VerifyTokenSchema = z.object({ token: z.string() });
8683

@@ -141,9 +138,10 @@ function createWebhookEngine() {
141138
tracer,
142139
meter,
143140
resolveSigningSecret: async (key) => {
144-
const value = await secretStore.getSecret(SigningSecretSchema, key);
141+
const value = await secretStore.getSecret(StoredSigningSecret, key);
145142
// Fail closed: an unset/empty secret returns undefined so ingest rejects.
146-
return value?.secret || undefined;
143+
const secrets = activeSigningSecrets(value, new Date());
144+
return secrets.length > 0 ? secrets : undefined;
147145
},
148146
resolveVerifyToken: async (endpointId) => {
149147
const value = await secretStore.getSecret(

‎apps/webapp/app/v3/webhookSigningSecret.server.ts‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@ import { randomBytes } from "node:crypto";
22
import { WebhookVerifierArtifact } from "@trigger.dev/core/v3";
33
import { prisma, webhookPrisma } from "~/db.server";
44
import { getSecretStore } from "~/services/secrets/secretStore.server";
5+
import { replaceStoredSigningSecret, StoredSigningSecret } from "./webhookSigningSecret";
56

67
type SecretEndpoint = { id: string; verifierArtifact: unknown };
78

9+
type StoreOptions = { previousSecretTtlSeconds?: number };
10+
11+
type StoreResult = { previousSecretExpiresAt?: string };
12+
813
export function webhookSigningSecretKey(endpointId: string): string {
914
return `webhook:signing-secret:${endpointId}`;
1015
}
@@ -16,28 +21,39 @@ function isAsymmetricWebhookEndpoint(endpoint: SecretEndpoint): boolean {
1621

1722
/**
1823
* Stores a signing secret (or, for an asymmetric source, the provider's public key) in the shape the
19-
* engine's `resolveSigningSecret` reads, and points the endpoint at it.
24+
* engine's `resolveSigningSecret` reads, and points the endpoint at it. With
25+
* `previousSecretTtlSeconds`, the secret it replaces keeps verifying deliveries for that long.
2026
*/
21-
export async function storeWebhookSigningSecret(endpoint: SecretEndpoint, secret: string) {
27+
export async function storeWebhookSigningSecret(
28+
endpoint: SecretEndpoint,
29+
secret: string,
30+
options: StoreOptions = {}
31+
): Promise<StoreResult> {
2232
const key = webhookSigningSecretKey(endpoint.id);
23-
await getSecretStore("DATABASE", { prismaClient: prisma }).setSecret(key, { secret });
33+
const secretStore = getSecretStore("DATABASE", { prismaClient: prisma });
34+
const ttl = options.previousSecretTtlSeconds ?? 0;
35+
const existing = ttl > 0 ? await secretStore.getSecret(StoredSigningSecret, key) : undefined;
36+
const stored = replaceStoredSigningSecret(existing, secret, ttl, new Date());
37+
await secretStore.setSecret(key, stored);
2438
await webhookPrisma.webhookEndpoint.update({
2539
where: { id: endpoint.id },
2640
data: { signingSecretKey: key },
2741
});
42+
return stored.previous ? { previousSecretExpiresAt: stored.previous.expiresAt } : {};
2843
}
2944

3045
/** Mints and stores a new shared secret, replacing any existing one. Refused for asymmetric sources. */
3146
export async function generateWebhookSigningSecret(
32-
endpoint: SecretEndpoint
33-
): Promise<{ ok: true; secret: string } | { ok: false; error: string }> {
47+
endpoint: SecretEndpoint,
48+
options: StoreOptions = {}
49+
): Promise<({ ok: true; secret: string } & StoreResult) | { ok: false; error: string }> {
3450
if (isAsymmetricWebhookEndpoint(endpoint)) {
3551
return {
3652
ok: false,
3753
error: "Cannot generate a secret for an asymmetric endpoint; set its public key instead.",
3854
};
3955
}
4056
const secret = `whsec_${randomBytes(32).toString("hex")}`;
41-
await storeWebhookSigningSecret(endpoint, secret);
42-
return { ok: true, secret };
57+
const stored = await storeWebhookSigningSecret(endpoint, secret, options);
58+
return { ok: true, secret, ...stored };
4359
}

0 commit comments

Comments
 (0)