From 593ade7c2010c46786c3fbec569a1dc7c13a126a Mon Sep 17 00:00:00 2001 From: morgan-coded <256248948+morgan-coded@users.noreply.github.com> Date: Fri, 29 May 2026 03:16:44 -0500 Subject: [PATCH 1/2] Add apiSecretKeyFallback for inbound HMAC validation during secret rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inbound HMAC validation (webhooks, Flow, fulfillment-service) only ever checked against config.apiSecretKey. During a client-secret rotation Shopify keeps signing requests with the previous (oldest non-revoked) secret for a window observed up to ~30 minutes after revocation, so once an app rotates apiSecretKey to the new secret those in-flight requests fail validation and are dropped — non-trivial volume on high-traffic topics like orders/create. Add an optional apiSecretKeyFallback config option, consulted only for inbound HMAC validation in validateHmacString (the shared path behind webhooks, Flow, and fulfillment-service). The primary secret is checked first; the fallback is tried only if the primary fails. Outbound signing (generateLocalHmac) is unchanged and always uses apiSecretKey. When a request validates via the fallback, a Warning-level log is emitted so operators can observe when Shopify has stopped using the old secret and the fallback can be safely removed. Fixes #3183 Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/webhook-hmac-secret-fallback.md | 5 + packages/apps/shopify-api/lib/base-types.ts | 19 +++ .../utils/__tests__/hmac-validator.test.ts | 128 +++++++++++++++++- .../shopify-api/lib/utils/hmac-validator.ts | 29 +++- 4 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 .changeset/webhook-hmac-secret-fallback.md diff --git a/.changeset/webhook-hmac-secret-fallback.md b/.changeset/webhook-hmac-secret-fallback.md new file mode 100644 index 0000000000..874fe6248c --- /dev/null +++ b/.changeset/webhook-hmac-secret-fallback.md @@ -0,0 +1,5 @@ +--- +'@shopify/shopify-api': minor +--- + +Added an optional `apiSecretKeyFallback` config option. When set, it is used as a secondary secret when validating **inbound** HMAC signatures (webhooks, Flow, and fulfillment-service requests), so requests are not dropped during a client-secret rotation while Shopify is still signing with the previous secret. Outbound signing continues to use `apiSecretKey` only. When a request validates against the fallback secret, a `Warning`-level log is emitted so you can tell when the old secret is no longer in use and the fallback can be removed. diff --git a/packages/apps/shopify-api/lib/base-types.ts b/packages/apps/shopify-api/lib/base-types.ts index 3f7b9ef43c..4b8b534cf4 100644 --- a/packages/apps/shopify-api/lib/base-types.ts +++ b/packages/apps/shopify-api/lib/base-types.ts @@ -26,6 +26,25 @@ export interface ConfigParams< * Also known as Client Secret in your Partner Dashboard. */ apiSecretKey: string; + /** + * An additional, secondary API secret key used only to validate **inbound** + * HMAC signatures (webhooks, Flow, and fulfillment-service requests). + * + * This exists to avoid dropping inbound requests during a client-secret + * rotation. Shopify signs outbound requests with the oldest non-revoked + * secret, so after you rotate `apiSecretKey` to the new secret there can be a + * window (observed up to ~30 minutes after revoking the old secret) where + * requests are still signed with the previous secret. Setting the previous + * secret here lets those requests continue to validate. + * + * When a request validates against this fallback (and not `apiSecretKey`), a + * `Warning`-level log is emitted so you can observe when Shopify has stopped + * using the old secret and it is safe to unset this value. + * + * Outbound signing (`generateLocalHmac`) always uses `apiSecretKey` and is + * unaffected by this option. + */ + apiSecretKeyFallback?: string; /** * The scopes your app needs to access the API. Not required if using Shopify managed installation. */ diff --git a/packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts b/packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts index 7cccbeb11c..ebbd94e70c 100644 --- a/packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts +++ b/packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts @@ -3,7 +3,14 @@ import crypto from 'crypto'; import {testConfig} from '../../__tests__/test-config'; import {AuthQuery} from '../../auth/oauth/types'; import * as ShopifyErrors from '../../error'; -import {HMACSignator, getCurrentTimeInSec} from '../hmac-validator'; +import { + HMACSignator, + getCurrentTimeInSec, + validateHmacFromRequestFactory, +} from '../hmac-validator'; +import {HmacValidationType} from '../types'; +import {ShopifyHeader, LogSeverity} from '../../types'; +import {type NormalizedRequest} from '../../../runtime'; import {shopifyApi} from '../..'; describe('validateHmac', () => { @@ -235,9 +242,128 @@ describe('validateHmac', () => { }); }); +describe('validateHmacFromRequest with apiSecretKeyFallback (secret rotation)', () => { + const primarySecret = 'new_secret_after_rotation'; + const oldSecret = 'old_secret_before_rotation'; + const rawBody = JSON.stringify({id: 12345, topic: 'orders/create'}); + + function buildWebhookRequest(signingSecret: string) { + const rawRequest: NormalizedRequest = { + method: 'POST', + url: 'https://my-app.example.com/webhooks', + headers: { + [ShopifyHeader.Hmac]: createBase64HmacSignature(rawBody, signingSecret), + [ShopifyHeader.Topic]: 'orders/create', + [ShopifyHeader.Domain]: 'test-shop.myshopify.com', + }, + }; + + return { + type: HmacValidationType.Webhook as const, + rawBody, + rawRequest, + }; + } + + test('accepts a request signed with the primary secret', async () => { + const shopify = shopifyApi(testConfig({apiSecretKey: primarySecret})); + + const result = await validateHmacFromRequestFactory(shopify.config)( + buildWebhookRequest(primarySecret), + ); + + expect(result.valid).toBe(true); + }); + + test('accepts a request signed with the fallback secret when configured', async () => { + const shopify = shopifyApi( + testConfig({ + apiSecretKey: primarySecret, + apiSecretKeyFallback: oldSecret, + }), + ); + + // Signed with the OLD secret — would be rejected without the fallback. + const result = await validateHmacFromRequestFactory(shopify.config)( + buildWebhookRequest(oldSecret), + ); + + expect(result.valid).toBe(true); + }); + + test('emits a Warning log when validation succeeds via the fallback secret', async () => { + const shopify = shopifyApi( + testConfig({ + apiSecretKey: primarySecret, + apiSecretKeyFallback: oldSecret, + }), + ); + + await validateHmacFromRequestFactory(shopify.config)( + buildWebhookRequest(oldSecret), + ); + + expect(shopify.config.logger.log).toHaveBeenCalledWith( + LogSeverity.Warning, + expect.stringContaining('apiSecretKeyFallback'), + ); + }); + + test('does not emit the fallback Warning when the primary secret matches', async () => { + const shopify = shopifyApi( + testConfig({ + apiSecretKey: primarySecret, + apiSecretKeyFallback: oldSecret, + }), + ); + + const result = await validateHmacFromRequestFactory(shopify.config)( + buildWebhookRequest(primarySecret), + ); + + expect(result.valid).toBe(true); + expect(shopify.config.logger.log).not.toHaveBeenCalledWith( + LogSeverity.Warning, + expect.stringContaining('apiSecretKeyFallback'), + ); + }); + + test('rejects a request signed with neither the primary nor the fallback secret', async () => { + const shopify = shopifyApi( + testConfig({ + apiSecretKey: primarySecret, + apiSecretKeyFallback: oldSecret, + }), + ); + + const result = await validateHmacFromRequestFactory(shopify.config)( + buildWebhookRequest('some_unrelated_secret'), + ); + + expect(result.valid).toBe(false); + }); + + test('rejects the fallback-signed request when no fallback is configured', async () => { + const shopify = shopifyApi(testConfig({apiSecretKey: primarySecret})); + + const result = await validateHmacFromRequestFactory(shopify.config)( + buildWebhookRequest(oldSecret), + ); + + expect(result.valid).toBe(false); + }); +}); + function createHmacSignature(queryString: string, apiSecretKey: string) { return crypto .createHmac('sha256', apiSecretKey) .update(queryString) .digest('hex'); } + +function createBase64HmacSignature(data: string, apiSecretKey: string) { + return crypto + .createHmac('sha256', apiSecretKey) + .update(data, 'utf8') + .digest('base64'); +} diff --git a/packages/apps/shopify-api/lib/utils/hmac-validator.ts b/packages/apps/shopify-api/lib/utils/hmac-validator.ts index d973c0b375..fa348897d9 100644 --- a/packages/apps/shopify-api/lib/utils/hmac-validator.ts +++ b/packages/apps/shopify-api/lib/utils/hmac-validator.ts @@ -105,10 +105,35 @@ export async function validateHmacString( data: string, hmac: string, format: HashFormat, -) { +): Promise { const localHmac = await createSHA256HMAC(config.apiSecretKey, data, format); - return safeCompare(hmac, localHmac); + if (safeCompare(hmac, localHmac)) { + return true; + } + + // During a client-secret rotation Shopify can keep signing inbound requests + // with the previous secret for a short window. If a fallback secret is + // configured, accept requests signed with it too, and surface a warning so + // operators know the old secret is still in use. + if (config.apiSecretKeyFallback) { + const fallbackHmac = await createSHA256HMAC( + config.apiSecretKeyFallback, + data, + format, + ); + + if (safeCompare(hmac, fallbackHmac)) { + await logger(config).warning( + 'Inbound HMAC validated using apiSecretKeyFallback. Shopify is still ' + + 'signing requests with the previous secret; keep the fallback set ' + + 'until these warnings stop, then unset it.', + ); + return true; + } + } + + return false; } export function getCurrentTimeInSec() { From 8edce9f1e80a0852b0f86de0f6a1896675e02e10 Mon Sep 17 00:00:00 2001 From: morgan-coded <256248948+morgan-coded@users.noreply.github.com> Date: Fri, 29 May 2026 09:50:01 -0500 Subject: [PATCH 2/2] test: cover fallback HMAC validation for Flow --- .../lib/utils/__tests__/hmac-validator.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts b/packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts index ebbd94e70c..2fa3a2de2f 100644 --- a/packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts +++ b/packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts @@ -352,6 +352,24 @@ describe('validateHmacFromRequest with apiSecretKeyFallback (secret rotation)', expect(result.valid).toBe(false); }); + + test('accepts a Flow request signed with the fallback secret when configured', async () => { + const shopify = shopifyApi( + testConfig({ + apiSecretKey: primarySecret, + apiSecretKeyFallback: oldSecret, + }), + ); + + // Flow and fulfillment-service share validateHmacString with webhooks, so + // the fallback must cover them too. Signed with the OLD secret. + const result = await validateHmacFromRequestFactory(shopify.config)({ + ...buildWebhookRequest(oldSecret), + type: HmacValidationType.Flow as const, + }); + + expect(result.valid).toBe(true); + }); }); function createHmacSignature(queryString: string, apiSecretKey: string) {