All things Shopify and commerce
Hello Community,
We’ve encountered an issue where Shopify sends us a webhook when a user requests shipping rates for the items in their shopping cart. This webhook includes a signature in the headers, which we verify using the client_secret from our Shopify app, linked to the client’s store.
The problem started occurring after January 2, 2024. The signature verification began failing for several of our clients. What’s puzzling is that it doesn’t affect all stores, and for some stores experiencing the issue, the verification works for certain products but fails for others.
We’ve tried adjusting our signature verification strategies without success. Below, I’ve attached our implementation using the Koa Framework:
import cors from '@koa/cors'; import { inject, injectable } from 'inversify'; import Koa from 'koa'; import { HttpMethodEnum, koaBody } from 'koa-body'; import cookie from 'koa-cookie'; import helmet from 'koa-helmet'; import json from 'koa-json'; import morgan from 'koa-morgan'; import rateLimit, { MiddlewareOptions } from 'koa-ratelimit'; import session from 'koa-session'; import koaStatic from 'koa-static'; import { TYPES } from '@/shared/domain/d-injection/types'; import { Framework } from '@/shared/domain/framework'; import { Logger } from '@/shared/domain/logger'; import { serverAdapter } from '@/shared/infrastructure/bull-board/bull.board'; import { SERVER, TEST } from '@/shared/infrastructure/config'; import { tracer } from '@/shared/infrastructure/logger/datadog.logger'; import authRouter, { authMiddleware } from '@/shared/infrastructure/middlewares/auth-board.middleware'; import { router, registerRoutes } from '@/shared/infrastructure/open-api/router'; // Rate limit db const db = new Map(); @injectable() export class KoaFramework implements Framework { private app: Koa; constructor(@inject(TYPES.Logger) private readonly logger: Logger) { this.app = new Koa(); } async init(): Promise<void> { // Session this.app.keys = ['your-session-secret']; this.app.use(session({}, this.app)); // Middleware this.app.use(cors()); this.app.use( helmet( SERVER.embedding.enabled ? { contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], imgSrc: ["'self'", '*.googleapis.com'], styleSrc: ["'self'", '*.googleapis.com'], 'frame-ancestors': [...SERVER.embedding.allowedOrigins] }, reportOnly: true }, frameguard: false // Note: https://github.com/helmetjs/helmet/issues/167 } : { contentSecurityPolicy: false } ) ); this.app.use(json()); this.app.use( koaBody({ multipart: true, includeUnparsed: true, parsedMethods: Object.values(HttpMethodEnum), onError: (err, ctx) => { ctx.throw(422, `Body parse error: ${err}`); } }) ); this.app.use(async (ctx, next) => { if (ctx?.request?.body?.[Symbol.for('unparsedBody')]) { ctx.request.rawBody = ctx.request.body[Symbol.for('unparsedBody')]; delete ctx.request.body[Symbol.for('unparsedBody')]; } await next(); }); this.app.use(cookie()); this.app.use( morgan('dev', { stream: { write: (message) => this.logger.debug(message) } }) ); // rateLimit per IP = max / duration * 1000 [req/s] this.app.use( rateLimit({ driver: 'memory', db, duration: SERVER.rateLimit.duration, max: SERVER.rateLimit.maxRequestsInDurationFrame, id: (ctx) => ctx.ip, headers: { remaining: 'Rate-Limit-Remaining', reset: 'Rate-Limit-Reset', total: 'Rate-Limit-Total' }, throw: true, disableHeader: false } as MiddlewareOptions) ); // Error handling middleware this.app.use(async (ctx: Koa.Context, next: Koa.Next) => { try { await next(); } catch (err: any) { const { body, headers, method, originalUrl, ip } = ctx.request; this.logger.registerLog({ type: 'WEB_FRAMEWORK_ERROR', message: `${err}`, module: 'SHARED', level: 'error', request: { body, headers, method, originalUrl, ip } }); ctx.status = ctx.status || 500; ctx.body = { message: err.toString() }; } finally { if (!TEST.isDefined) { const span = tracer.scope().active(); if (span) span.finish(); } } }); // Bull board this.app.use(authRouter.routes()); this.app.use(authRouter.allowedMethods()); this.app.use(authMiddleware); this.app.use(serverAdapter.registerPlugin()); // End Bull board registerRoutes(); this.app.use(router.routes()); this.app.use(router.allowedMethods()); this.app.use( koaStatic( `${require('path').resolve()}/src/shared/infrastructure/layouts` ) ); const port = SERVER.port; this.app.listen(port, () => this.logger.debug(`[Koa] Started in ${SERVER.hostname}:${port}`) ); } getApp(): Koa { return this.app; } }
Here is how we validate the signatures:
/**
* Validates a given HMAC against a secret.
* @param hmac - The HMAC to be validated.
* @param platFormOrigin - The platform origin ex. 'shopify'.
* @param body - The body data.
* @param secret - The secret key to extract the value in the config file, ex. 'shopifySecret'.
* @returns Boolean indicating whether the HMAC is valid.
*/
export const validateHmac = (
hmac: string,
secretValue: string,
body: any,
encoding: EncodingType = 'Base64'
): boolean => {
const crypto = new CryptoUtil();
// Sometimes we may receive a rawBody
const transFormBody = typeof body !== 'string' ? JSON.stringify(body) : body;
return crypto.verifyHmac(hmac, secretValue, transFormBody, encoding);
};
We use the following CryptoUtil class for HMAC generation and verification:
import CryptoJS from 'crypto-js';
import { injectable } from 'inversify';
import { Crypto, EncodingType } from '@/shared/domain/utils/crypto';
@injectable()
export class CryptoUtil implements Crypto {
/**
* @param secret - The secret key to use for HMAC generation.
* @param payload - The data to be hashed.
* @param encoding - The encoding type for the generated HMAC (e.g., 'Hex', 'Base64', 'Latin1', etc.).
* @returns The generated HMAC.
*/
public generateHmac(
secret: string,
payload: any,
encoding: EncodingType = 'Base64'
😞 string {
return CryptoJS.HmacSHA256(payload, secret).toString(
CryptoJS.enc[encoding]
);
}
/**
* @param hmac - The HMAC to verify.
* @param secret - The secret key to use for verification.
* @param payload - The data to verify against.
* @param [encoding] - The encoding type for the generated HMAC (e.g., 'Hex', 'Base64', 'Latin1', etc.).
* @returns Boolean indicating whether the verification was successful.
*/
public verifyHmac(
hmac: string,
secret: string,
payload: string,
encoding?: EncodingType
😞 boolean {
const calculatedHmac = this.generateHmac(secret, payload, encoding);
return hmac === calculatedHmac;
}
}
Additionally, we attempted using the shopify-api library for Node.js and followed its documented webhook verification process but encountered the same issues described above. As well as changing crypto-js library for crypto.
Has anyone else experienced a similar problem, or could you suggest an approach to resolve this?
Thank you in advance for your help!
Hey Community! As we jump into 2025, we want to give a big shout-out to all of you wh...
By JasonH Jan 7, 2025Hey Community! As the holiday season unfolds, we want to extend heartfelt thanks to a...
By JasonH Dec 6, 2024Dropshipping, a high-growth, $226 billion-dollar industry, remains a highly dynamic bus...
By JasonH Nov 27, 2024