diff --git a/apps/docs/content/docs/shopify/index.mdx b/apps/docs/content/docs/shopify/index.mdx index 85bee7d5..3a672fc1 100644 --- a/apps/docs/content/docs/shopify/index.mdx +++ b/apps/docs/content/docs/shopify/index.mdx @@ -46,7 +46,7 @@ All read operations are cached with `"use cache"` and `cacheLife("max")`. Withou POST /api/webhooks/shopify ``` -The handler verifies the HMAC signature using the `SHOPIFY_WEBHOOK_SECRET` environment variable, then calls `revalidateTag()` for the affected cache tags. +The handler requires the `SHOPIFY_WEBHOOK_SECRET` environment variable, verifies the HMAC signature, then calls `revalidateTag()` for the affected cache tags. ### Supported topics @@ -74,7 +74,7 @@ In your Shopify admin, go to **Settings → Notifications → Webhooks**: SHOPIFY_WEBHOOK_SECRET="your-webhook-signing-secret" ``` -> **Important:** Without `SHOPIFY_WEBHOOK_SECRET`, the handler skips signature verification. Always set this in production. +> **Required:** `SHOPIFY_WEBHOOK_SECRET` must be set with a non-empty value. Requests hitting the endpoint without the secret configured return `500`; requests with a bad or missing `x-shopify-hmac-sha256` header return `401`. ### How it works diff --git a/apps/template/.env.example b/apps/template/.env.example index b50052b7..425445a2 100644 --- a/apps/template/.env.example +++ b/apps/template/.env.example @@ -4,3 +4,7 @@ SHOPIFY_STOREFRONT_ACCESS_TOKEN="your-storefront-access-token" # Store display name (shown in header, metadata, etc.) NEXT_PUBLIC_SITE_NAME="Your Store Name" + +# Required if using Shopify webhooks for cache invalidation +# Copy from Shopify Admin > Settings > Notifications > Webhooks +SHOPIFY_WEBHOOK_SECRET="your-webhook-signing-secret" diff --git a/apps/template/app/api/webhooks/shopify/route.ts b/apps/template/app/api/webhooks/shopify/route.ts index b958ce8a..1bd13ea3 100644 --- a/apps/template/app/api/webhooks/shopify/route.ts +++ b/apps/template/app/api/webhooks/shopify/route.ts @@ -4,22 +4,29 @@ import { revalidateTag } from "next/cache"; import { getNumericShopifyId } from "@/lib/shopify/utils"; -const SHOPIFY_WEBHOOK_SECRET = process.env.SHOPIFY_WEBHOOK_SECRET; - /** * Verify Shopify webhook HMAC signature */ -async function verifyWebhook(body: string, hmacHeader: string | null): Promise { - if (!SHOPIFY_WEBHOOK_SECRET || !hmacHeader) { +async function verifyWebhook( + secret: string, + body: string, + hmacHeader: string | null, +): Promise { + if (!hmacHeader) { return false; } - const hash = crypto - .createHmac("sha256", SHOPIFY_WEBHOOK_SECRET) - .update(body, "utf8") - .digest("base64"); + const expected = Buffer.from( + crypto.createHmac("sha256", secret).update(body, "utf8").digest("base64"), + "base64", + ); + const received = Buffer.from(hmacHeader, "base64"); + + if (expected.length !== received.length) { + return false; + } - return crypto.timingSafeEqual(Buffer.from(hash, "base64"), Buffer.from(hmacHeader, "base64")); + return crypto.timingSafeEqual(expected, received); } /** @@ -33,17 +40,20 @@ async function verifyWebhook(body: string, hmacHeader: string | null): Promise Notifications > Webhooks */ export async function POST(request: Request) { + const secret = process.env.SHOPIFY_WEBHOOK_SECRET; + if (!secret) { + console.error("SHOPIFY_WEBHOOK_SECRET is not set"); + return Response.json({ error: "Webhook secret not configured" }, { status: 500 }); + } + const body = await request.text(); const hmacHeader = request.headers.get("x-shopify-hmac-sha256"); const topic = request.headers.get("x-shopify-topic"); - // Verify webhook signature in production - if (SHOPIFY_WEBHOOK_SECRET) { - const isValid = await verifyWebhook(body, hmacHeader); - if (!isValid) { - console.error("Invalid Shopify webhook signature"); - return Response.json({ error: "Invalid signature" }, { status: 401 }); - } + const isValid = await verifyWebhook(secret, body, hmacHeader); + if (!isValid) { + console.error("Invalid Shopify webhook signature"); + return Response.json({ error: "Invalid signature" }, { status: 401 }); } if (!topic) { diff --git a/packages/plugin/template-rollout-log/2026-04-18-webhook-secret-required.md b/packages/plugin/template-rollout-log/2026-04-18-webhook-secret-required.md new file mode 100644 index 00000000..df1ded68 --- /dev/null +++ b/packages/plugin/template-rollout-log/2026-04-18-webhook-secret-required.md @@ -0,0 +1,33 @@ +--- +title: Require SHOPIFY_WEBHOOK_SECRET for webhook handler +changeKey: webhook-secret-required +introducedOn: 2026-04-18 +changeType: breaking +defaultAction: adopt +appliesTo: + - all +paths: + - app/api/webhooks/shopify/route.ts +--- + +## Summary + +The Shopify webhook handler no longer skips HMAC verification when `SHOPIFY_WEBHOOK_SECRET` is unset. It now returns `500 Webhook secret not configured` when the env var is missing or empty, and `401 Invalid signature` when the signature does not match. + +## Why it matters + +The previous fallback of "no secret = accept every request" was a silent cache-invalidation vector. Any unauthenticated caller could flush product, collection, inventory, and CMS caches by POSTing to `/api/webhooks/shopify`. Failing closed removes that footgun. + +## Apply when + +Any storefront that exposes `/api/webhooks/shopify` to the internet, which is every storefront deployed to Vercel. Adopt this unconditionally. + +## Safe to skip when + +Never safe to skip. The previous behavior was unsafe in production. + +## Validation + +- Set `SHOPIFY_WEBHOOK_SECRET` in Vercel project env vars (copy from Shopify Admin → Settings → Notifications → Webhooks). +- `curl -X POST https://your-domain/api/webhooks/shopify` with no headers → expect `401` (or `500` if the env var is not set). +- Trigger a real Shopify webhook → expect `200` with `tagsInvalidated` in the response.