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
4 changes: 2 additions & 2 deletions apps/docs/content/docs/shopify/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions apps/template/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
42 changes: 26 additions & 16 deletions apps/template/app/api/webhooks/shopify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
if (!SHOPIFY_WEBHOOK_SECRET || !hmacHeader) {
async function verifyWebhook(
secret: string,
body: string,
hmacHeader: string | null,
): Promise<boolean> {
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);
}

/**
Expand All @@ -33,17 +40,20 @@ async function verifyWebhook(body: string, hmacHeader: string | null): Promise<b
* Settings > 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.