How to Create Secure Sanity CMS Webhooks with Next.js App Router
Implement HMAC signature verification and selective revalidation using @sanity/webhook in App Router

I was building a blog with selective cache invalidation when I realized that my webhook endpoint was completely unsecured. Anyone could hit my revalidation API and trigger unnecessary rebuilds, potentially causing performance issues or even abuse. After diving into Sanity's official documentation and webhook toolkit, I discovered the proper way to implement enterprise-level webhook security with signature verification.
This guide walks you through creating a production-ready webhook endpoint in Next.js App Router that securely receives and validates requests from Sanity CMS. By the end, you'll have a bulletproof webhook system that only accepts legitimate requests from your Sanity project.
The Security Challenge with Webhooks
When Sanity sends a webhook to your Next.js application, you need to verify two things: first, that the request actually came from Sanity, and second, that it contains valid data for your specific project. Without proper verification, malicious actors could trigger your webhook endpoints, potentially causing unwanted cache invalidations, database updates, or resource consumption.
The traditional approach of checking a simple shared secret isn't sufficient for production applications. Modern webhook security requires cryptographic signature verification, which ensures both authenticity and data integrity. Fortunately, Sanity provides an official toolkit that handles this complexity for us.
Setting Up the Webhook Endpoint
Let's start by creating our secure webhook endpoint in the Next.js App Router. The new router structure places API routes in the app
directory with a specific file naming convention.
// File: src/app/api/sanity-webhook/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { revalidatePath } from 'next/cache'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
console.log('Webhook received:', body)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Webhook error:', error)
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
)
}
}
This basic endpoint accepts POST requests and logs the incoming data. However, it's completely unsecured at this point. Any external service could send requests to this endpoint, which is exactly what we need to prevent.
Installing the Sanity Webhook Toolkit
Sanity provides an official package that handles webhook signature verification using industry-standard cryptographic methods. This toolkit implements the same verification logic used by major platforms like GitHub and Stripe.
pnpm add @sanity/webhook
The @sanity/webhook
package provides functions for validating request signatures, reading request bodies correctly, and handling the complexities of cryptographic verification. The key advantage of using the official toolkit is that it's maintained by the Sanity team and follows their exact signature generation process.
Implementing Signature Verification
Now we need to modify our endpoint to verify that requests actually come from Sanity. This requires reading the raw request body and comparing its cryptographic signature against what Sanity sends in the headers.
// File: src/app/api/sanity-webhook/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { revalidatePath } from 'next/cache'
import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook'
const WEBHOOK_SECRET = process.env.SANITY_WEBHOOK_SECRET
async function readBody(request: NextRequest): Promise<string> {
const chunks = []
const reader = request.body?.getReader()
if (!reader) {
throw new Error('Request body is not readable')
}
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
}
const concatenated = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0))
let offset = 0
for (const chunk of chunks) {
concatenated.set(chunk, offset)
offset += chunk.length
}
return new TextDecoder().decode(concatenated)
}
export async function POST(request: NextRequest) {
try {
// Read the raw body for signature verification
const body = await readBody(request)
// Verify webhook signature if secret is provided
if (WEBHOOK_SECRET) {
const signature = request.headers.get(SIGNATURE_HEADER_NAME)
if (!signature) {
return NextResponse.json(
{ error: 'Missing webhook signature' },
{ status: 401 }
)
}
const isValid = await isValidSignature(body, signature, WEBHOOK_SECRET)
if (!isValid) {
return NextResponse.json(
{ error: 'Invalid webhook signature' },
{ status: 401 }
)
}
}
// Parse the validated body
const data = JSON.parse(body)
console.log('Verified webhook received:', data)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Webhook error:', error)
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
)
}
}
The readBody
function is crucial here because signature verification requires the exact raw bytes that Sanity sent. The isValidSignature
function performs HMAC-SHA256 verification using your secret key, ensuring that only Sanity can generate valid signatures for your webhook.
The SIGNATURE_HEADER_NAME
constant automatically uses the correct header name that Sanity sends (sanity-webhook-signature
), so you don't need to hardcode header names or worry about changes to Sanity's implementation.
Configuring GROQ Filters and Projections
One of the most powerful features of Sanity webhooks is the ability to filter and shape the data before it's sent to your endpoint. This reduces bandwidth and ensures your webhook only receives relevant updates.
In your Sanity Studio dashboard, navigate to the webhook configuration and set up these parameters:
Filter (GROQ query to determine which documents trigger the webhook):
_type in ['post', 'category', 'author']
This filter ensures the webhook only fires for blog-related content changes, ignoring updates to other document types like site settings or navigation items.
Projection (GROQ query to shape the payload data):
{ _id, _type, slug, "categories": categories[]->slug.current }
The projection sends only the essential data your endpoint needs: the document ID, type, slug object, and related category slugs. This approach reduces payload size and eliminates sensitive information from webhook requests.
Notice that we project the entire slug
object rather than slug.current
. This prevents issues when documents are being created and the slug might not exist yet. Your endpoint can safely access slug.current
or slug?.current
as needed.
Processing Different Document Types
With the filter and projection configured, your webhook endpoint will receive structured data for different content types. Here's how to handle each type appropriately:
// File: src/app/api/sanity-webhook/route.ts (continued from previous example)
export async function POST(request: NextRequest) {
try {
// ... signature verification code from above ...
const data = JSON.parse(body)
const { _type, slug, _id, categories } = data
console.log('Sanity webhook received:', { _type, slug: slug?.current, _id })
if (_type === 'post') {
const paths = []
// Revalidate specific post if slug exists
if (slug?.current) {
revalidatePath(`/blog/${slug.current}`)
paths.push(`/blog/${slug.current}`)
}
// Always revalidate blog listing page
revalidatePath('/blog')
paths.push('/blog')
console.log('Revalidated paths:', paths)
return NextResponse.json({
revalidated: true,
paths,
timestamp: new Date().toISOString()
})
}
if (_type === 'category') {
revalidatePath('/blog')
return NextResponse.json({
revalidated: true,
paths: ['/blog'],
timestamp: new Date().toISOString()
})
}
if (_type === 'author') {
revalidatePath('/blog')
return NextResponse.json({
revalidated: true,
paths: ['/blog'],
timestamp: new Date().toISOString()
})
}
// Handle unexpected document types gracefully
return NextResponse.json({
message: `Received ${_type} update but no action configured`,
timestamp: new Date().toISOString()
})
} catch (error) {
console.error('Revalidation webhook error:', error)
return NextResponse.json(
{ error: 'Failed to process webhook', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}
This implementation handles different document types with appropriate cache invalidation strategies. Post updates revalidate both the specific post page and the blog listing, while category and author changes only affect the listing page.
The error handling provides detailed logging while returning generic error messages to external callers, preventing information leakage about your internal systems.
Setting Up Environment Variables and Webhook Secret
Your webhook secret should be stored securely in environment variables. Add this to your .env.local
file:
SANITY_WEBHOOK_SECRET=your-secure-random-secret-here
Generate a strong, random secret using a password manager or command line tool. In production, this secret should be at least 32 characters long and contain a mix of letters, numbers, and symbols.
In your Sanity Studio webhook configuration, add the same secret value to the "Secret" field. Sanity will use this secret to generate cryptographic signatures for each webhook request, and your endpoint will use it to verify those signatures.
Testing the Webhook Integration
Once your endpoint is deployed and configured, you can test the integration by making changes to your Sanity content. Create or update a blog post, and check your application logs for webhook activity.
You should see logs similar to:
Sanity webhook received: { _type: 'post', slug: 'my-blog-post', _id: '236294f4-af58-4841-acfb-3e59c8a2f3ca' }
Revalidated paths: [ '/blog/my-blog-post', '/blog' ]
If you see "Invalid webhook signature" errors, double-check that your environment variable matches exactly what you configured in Sanity. Any difference in the secret will cause signature verification to fail.
For debugging purposes, you can temporarily disable signature verification by commenting out the WEBHOOK_SECRET environment variable, but never deploy to production without proper verification enabled.
Production Considerations
When deploying this webhook system to production, consider these additional security and reliability measures:
Rate limiting can prevent abuse even with valid signatures. Consider implementing request throttling based on IP address or request frequency to protect against unexpected webhook storms.
Logging and monitoring help you track webhook performance and catch issues early. Log successful operations, failed signature verifications, and processing errors with appropriate detail levels.
Error handling should be comprehensive but not verbose to external callers. Your webhook should return appropriate HTTP status codes while logging detailed error information internally for debugging.
The webhook secret should be rotated periodically as part of your security maintenance routine. When rotating secrets, update both your environment variables and Sanity webhook configuration simultaneously to prevent service interruption.
Conclusion
Creating secure Sanity CMS webhooks with Next.js App Router requires proper signature verification, careful body parsing, and thoughtful GROQ filtering. By using Sanity's official webhook toolkit, you get enterprise-level security with minimal complexity.
The implementation we've built provides cryptographic verification of webhook authenticity, selective content filtering to reduce bandwidth, and appropriate error handling for production use. Your webhook endpoint now only accepts legitimate requests from your Sanity project and can safely trigger cache invalidations or other sensitive operations.
This approach scales well for larger applications and provides the security foundation needed for production CMS integrations. Let me know in the comments if you have questions about implementing webhook security, and subscribe for more practical development guides.
Thanks, Matija