Sanity CMS Security Guide for Vibe Coders

Share

TL;DR

Sanity is a headless CMS with a powerful query language (GROQ). Use read-only tokens for public content fetching. Keep write tokens server-side only. Be careful with GROQ queries that include user input - use parameters instead of string interpolation. Configure CORS properly to restrict which domains can access your content API. Verify webhook signatures before processing.

Why Sanity Security Matters for Vibe Coding

Sanity provides a flexible content backend that many developers use for blogs, e-commerce, and applications. When AI tools generate Sanity integration code, they often create working queries but may expose tokens or create GROQ injection vulnerabilities.

API Token Management

# .env.local (never commit)
# Project configuration (safe for client)
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=production

# Read token for public content (can be client-side for public data)
NEXT_PUBLIC_SANITY_API_TOKEN=skxxxxread

# Write token (server-side ONLY)
SANITY_API_WRITE_TOKEN=skxxxxwrite

Token Permissions

Create tokens with minimal permissions in the Sanity dashboard:

  • Viewer - Read-only access to published content
  • Editor - Read and write access (use server-side only)
  • Deploy Studio - For CI/CD deployments

Token Exposure Risk: If your write token is exposed, attackers can modify or delete all your content. Always use read-only tokens for client-side code and keep write tokens in server-side environment variables only.

GROQ Query Safety

GROQ is Sanity's query language. Like SQL, it can be vulnerable to injection if you concatenate user input:

import { createClient } from '@sanity/client';

const client = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
  apiVersion: '2024-01-01',
  useCdn: true,
});

// DANGEROUS: String interpolation
async function searchPosts(searchTerm: string) {
  // User could inject: `" || true || title == "`
  const query = `*[_type == "post" && title match "${searchTerm}"]`;
  return client.fetch(query);
}

// SAFE: Use parameters
async function searchPostsSafe(searchTerm: string) {
  const query = `*[_type == "post" && title match $search]`;
  return client.fetch(query, { search: `${searchTerm}*` });
}

// SAFE: Parameterized queries
async function getPostBySlug(slug: string) {
  const query = `*[_type == "post" && slug.current == $slug][0]`;
  return client.fetch(query, { slug });
}

// SAFE: Multiple parameters
async function getPostsByCategory(category: string, limit: number) {
  const query = `*[_type == "post" && category == $category] | order(publishedAt desc)[0...$limit]`;
  return client.fetch(query, { category, limit });
}

Content Access Control

// Fetching only published content
const query = `*[_type == "post" && !(_id in path("drafts.**"))]`;

// With authentication check for drafts
async function getPost(slug: string, preview: boolean) {
  if (preview) {
    // Requires authentication - use server-side only
    const query = `*[_type == "post" && slug.current == $slug][0]`;
    return previewClient.fetch(query, { slug });
  }

  // Public - only published content
  const query = `*[_type == "post" && slug.current == $slug && !(_id in path("drafts.**"))][0]`;
  return client.fetch(query, { slug });
}

CORS Configuration

Configure CORS in your Sanity dashboard to restrict API access:

// sanity.config.ts - Document your allowed origins
// Actual CORS is configured in Sanity dashboard

// Allowed origins should be:
// - https://yourdomain.com
// - https://www.yourdomain.com
// - http://localhost:3000 (development only)

// NEVER allow:
// - * (all origins)
// - http://*.yourdomain.com (wildcard subdomains)

Webhook Security

import crypto from 'crypto';

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get('sanity-webhook-signature');

  if (!signature) {
    return Response.json({ error: 'Missing signature' }, { status: 401 });
  }

  // Verify webhook signature
  const expectedSignature = crypto
    .createHmac('sha256', process.env.SANITY_WEBHOOK_SECRET!)
    .update(body)
    .digest('hex');

  if (signature !== expectedSignature) {
    return Response.json({ error: 'Invalid signature' }, { status: 401 });
  }

  // Safe to process webhook
  const payload = JSON.parse(body);

  // Revalidate cache, trigger builds, etc.
  if (payload._type === 'post') {
    await revalidatePath(`/blog/${payload.slug.current}`);
  }

  return Response.json({ success: true });
}

Preventing Data Leaks

// Be explicit about what fields you return
// RISKY: Returns all fields including internal ones
const query = `*[_type == "user"]`;

// SAFE: Explicit field selection
const query = `*[_type == "user"]{
  _id,
  name,
  avatar,
  bio
  // Don't include: email, role, internalNotes
}`;

// For user-specific data, verify ownership
async function getUserProfile(userId: string, requesterId: string) {
  // Only return full profile if user is viewing their own
  if (userId !== requesterId) {
    const query = `*[_type == "user" && _id == $userId][0]{
      name,
      avatar,
      bio
    }`;
    return client.fetch(query, { userId });
  }

  // Full profile for own user
  const query = `*[_type == "user" && _id == $userId][0]{
    name,
    email,
    avatar,
    bio,
    settings
  }`;
  return client.fetch(query, { userId });
}

Sanity Security Checklist

  • Write tokens stored server-side only
  • Read-only tokens used for public content fetching
  • GROQ queries use parameters, not string interpolation
  • CORS configured with specific domains (no wildcards)
  • Webhooks verify signature before processing
  • Draft content requires authentication to view
  • Queries explicitly select fields (no implicit *)
  • Sensitive fields excluded from public queries

Can I use Sanity tokens on the client side?

Read-only tokens can be used client-side for public content. Never expose write tokens or tokens that can access draft/private content to the client.

Is GROQ vulnerable to injection like SQL?

Yes, if you concatenate user input into GROQ queries. Always use parameterized queries with the second argument to fetch(). Parameters are properly escaped.

How do I secure preview mode?

Preview mode should require authentication. Use a secret token in the preview URL and verify it server-side before showing draft content. Never expose draft content to unauthenticated users.

Scan Your Sanity Integration

Find GROQ injection vulnerabilities, exposed tokens, and access control issues before they reach production.

Start Free Scan
Tool & Platform Guides

Sanity CMS Security Guide for Vibe Coders