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
- App running with
apiSecretKey = S1.
- In Partners: create
S2, then revoke S1 per the documented procedure.
- Deploy with
apiSecretKey = S2.
- 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)
- In Shopify Partners, create a new client secret. The current secret (
S_old) remains valid.
- Update env vars: set
SHOPIFY_API_SECRET = S_new and SHOPIFY_API_SECRET_FALLBACK = S_old.
- 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 }.
- In Shopify Partners, revoke
S_old.
- Watch the warn logs. As long as they keep appearing, Shopify is still signing webhooks with
S_old. Do not unset the fallback yet.
- 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
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-apiaccepts a singleapiSecretKeywith no way to trust a second secret, so every such webhook returns 401 fromauthenticate.webhook(). On high-volume topics (orders/create,orders/updated) the drop volume is not negligible.Reproduction
apiSecretKey = S1.S2, then revokeS1per the documented procedure.apiSecretKey = S2.S1all return 401.Delaying revocation is not a mitigation: Shopify signs with whichever secret is oldest-non-revoked at send time, so as long as
S1remains non-revoked,S1-signed webhooks continue indefinitely. Revocation is what triggers the ~30-min drop zone.Proposed API
Additive, backward compatible, inbound-only:
apiSecretKeyremains the single secret used for outbound signing (generateLocalHmac). Unchanged.apiSecretKeyFallback, when set, is consulted only for inbound HMAC validation. If both secrets fail thesafeCompare, the request is rejected as before.warn-level log (via the existinglogger(config)used byhmac-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.tsplus a type addition inbase-types.tsand corresponding test coverage.Target rotation procedure (with proposed API)
S_old) remains valid.SHOPIFY_API_SECRET = S_newandSHOPIFY_API_SECRET_FALLBACK = S_old.warn: webhook HMAC validated via apiSecretKeyFallback { topic, shop, webhookId }.S_old.S_old. Do not unset the fallback yet.SHOPIFY_API_SECRET_FALLBACKand 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:
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