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

·Matija Žiberna·
How to Create Secure Sanity CMS Webhooks with Next.js 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

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in