Sanity CMS Security Guide for Vibe Coders

TL;DR

Sanity is a headless CMS with its own query language, GROQ. Use read-only tokens for public content and keep write tokens server-side. When a GROQ query includes user input, pass it as a parameter instead of interpolating it into the string. Restrict CORS to the domains that actually need your content API, and verify webhook signatures before processing anything.

Why Sanity Security Matters for Vibe Coding

Plenty of blogs and stores run on Sanity as their content backend. When an AI tool writes the integration, the queries usually work. The trouble is what comes with them: a token in the wrong place, or a query built by gluing user input into a string.

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 in the Sanity dashboard with the least access each one needs:

  • Viewer: read-only access to published content
  • Editor: read and write access (server-side only)
  • Deploy Studio: for CI/CD deployments

Token Exposure Risk: An exposed write token lets anyone modify or delete all your content. Client-side code gets read-only tokens. Write tokens stay in server-side environment variables.

GROQ Query Safety

GROQ is Sanity's query language, and like SQL it's injectable when 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

CORS lives in the Sanity dashboard, not in your code. Lock it down there:

// 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 are fine client-side for public content. Never send a write token, or any token that can read drafts or private content, to the browser.

Is GROQ vulnerable to injection like SQL?

Yes, if you concatenate user input into the query. Pass values as parameters in the second argument to fetch() instead. Parameters are escaped properly.

How do I secure preview mode?

Require authentication. Put a secret token in the preview URL and check it server-side before showing any draft. Unauthenticated visitors should never see draft content.

Scan Your Sanity Integration

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

Tool & Platform Guides

Sanity CMS Security Guide for Vibe Coders