Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/webhook-hmac-secret-fallback.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions packages/apps/shopify-api/lib/base-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
146 changes: 145 additions & 1 deletion packages/apps/shopify-api/lib/utils/__tests__/hmac-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -235,9 +242,146 @@ 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);
});

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) {
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');
}
29 changes: 27 additions & 2 deletions packages/apps/shopify-api/lib/utils/hmac-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,35 @@ export async function validateHmacString(
data: string,
hmac: string,
format: HashFormat,
) {
): Promise<boolean> {
const localHmac = await createSHA256HMAC(config.apiSecretKey, data, format);

return safeCompare(hmac, localHmac);
if (safeCompare(hmac, localHmac)) {
return true;
}
Comment on lines +111 to +113

// 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() {
Expand Down
Loading