Skip to content

Webhooks dropped for ~30 min post-revocation: @shopify/shopify-api has no way to accept a second (rotating) secret #3183

Description

@jeanbaptistelaine

Summary

Shopify's client-secret rotation docs describe webhooks being signed with "the oldest non-revoked secret" during rotation, with revocation as the final step.

In practice, we observed webhooks continuing to be signed with a revoked secret for up to ~30 minutes after revocation in Partners. @shopify/shopify-api accepts a single apiSecretKey with no way to trust a second secret, so every such webhook returns 401 from authenticate.webhook(). On high-volume topics (orders/create, orders/updated) the drop volume is not negligible.

Reproduction

  1. App running with apiSecretKey = S1.
  2. In Partners: create S2, then revoke S1 per the documented procedure.
  3. Deploy with apiSecretKey = S2.
  4. For up to ~30 minutes, inbound webhooks signed with S1 all return 401.

Delaying revocation is not a mitigation: Shopify signs with whichever secret is oldest-non-revoked at send time, so as long as S1 remains non-revoked, S1-signed webhooks continue indefinitely. Revocation is what triggers the ~30-min drop zone.

Proposed API

Additive, backward compatible, inbound-only:

shopifyApp({
	apiSecretKey: process.env.SHOPIFY_API_SECRET,
	apiSecretKeyFallback: process.env.SHOPIFY_API_SECRET_FALLBACK,
	// ...
});
  • apiSecretKey remains the single secret used for outbound signing (generateLocalHmac). Unchanged.
  • apiSecretKeyFallback, when set, is consulted only for inbound HMAC validation. If both secrets fail the safeCompare, the request is rejected as before.
  • When validation succeeds via the fallback secret, the SDK emits a warn-level log (via the existing logger(config) used by hmac-validator.ts) with { topic, shop, webhookId }. This is the completion signal operators watch to know when to unset the fallback.

Implementation is localized to packages/apps/shopify-api/lib/utils/hmac-validator.ts plus a type addition in base-types.ts and corresponding test coverage.

Target rotation procedure (with proposed API)

  1. In Shopify Partners, create a new client secret. The current secret (S_old) remains valid.
  2. Update env vars: set SHOPIFY_API_SECRET = S_new and SHOPIFY_API_SECRET_FALLBACK = S_old.
  3. Deploy your app. It now accepts webhooks signed with either secret. Each webhook validated via the fallback secret emits a warn log, e.g. warn: webhook HMAC validated via apiSecretKeyFallback { topic, shop, webhookId }.
  4. In Shopify Partners, revoke S_old.
  5. Watch the warn logs. As long as they keep appearing, Shopify is still signing webhooks with S_old. Do not unset the fallback yet.
  6. Once the warn logs stop (typically ~30 min post-revocation and with no new occurrences for a safety margin), unset SHOPIFY_API_SECRET_FALLBACK and redeploy.

No webhooks dropped. Step 6 is observable (log-driven) rather than guessed.

Current user-space workaround

Validate HMAC in user space against both secrets, re-sign with the primary, delegate to the SDK so session lookup / topic parsing / admin client stay the SDK's concern:

export const authenticateWebhookWithFallback = async (request: Request) => {
	const primary = process.env.SHOPIFY_API_SECRET ?? '';
	const fallback = process.env.SHOPIFY_API_SECRET_FALLBACK;

	if (!fallback) return authenticate.webhook(request);

	const rawBody = await request.text();
	const received = request.headers.get('x-shopify-hmac-sha256') ?? '';

	const rebuild = (hmacOverride?: string) => {
		const headers = new Headers(request.headers);
		if (hmacOverride) headers.set('x-shopify-hmac-sha256', hmacOverride);
		return new Request(request.url, { method: request.method, headers, body: rawBody });
	};

	const primaryHmac = computeHmac(rawBody, primary);
	if (safeEqual(received, primaryHmac)) return authenticate.webhook(rebuild());

	if (safeEqual(received, computeHmac(rawBody, fallback))) {
		return authenticate.webhook(rebuild(primaryHmac)); // re-signed with primary
	}

	return authenticate.webhook(rebuild());
};

Works, but every Shopify app ends up maintaining ~40 lines of security-adjacent code independently, and the re-sign-then-delegate pattern is a workaround rather than a principled mechanism.

Willingness to contribute

Happy to open a PR with tests and a changeset. Open to adjusting the API shape (naming, webhook-only vs admin-HMAC scope, etc.) based on maintainer preference.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    devtools-gardenerPost the issue or PR to Slack for the gardener

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions