
Securing Your Sanity Project: API Tokens and CORS
To secure a Sanity project, give every application its own robot token with the least privileged role it needs, keep those tokens in server-only environment variables, and never ship a token in browser JavaScript. Then restrict CORS origins to the exact sites that must call the API from a browser, allow credentials only for origins that host a Studio, and avoid platform-wide wildcards. Add private datasets for content that shouldn't be public, sign your webhooks, and rotate or expire tokens on a schedule.
Most Sanity security incidents aren't clever attacks. They're a write token pasted into a NEXT_PUBLIC_ variable, a https://*.vercel.app CORS rule with credentials turned on, or a token created for a quick script that never got deleted. This guide covers how Sanity authenticates requests, the types of tokens, how to store and use them in a Next.js App Router project, how CORS really works, dataset visibility, accepting user submissions safely, webhook signatures and a rotation routine you can actually follow.
How Sanity Authenticates Requests
Every request to the Content Lake is either anonymous or carries a bearer token in the Authorization header:
Authorization: Bearer sk...your-token...
What an anonymous request can do depends on the dataset:
- Public dataset: anyone can read published documents. Drafts and release versions stay private, because anonymous requests can only read documents whose ID contains no dot.
- Private dataset: anonymous reads return empty results. Every read needs a token.
- Writes: always need a token with write permissions, on any dataset.
Sanity Studio authenticates editors through a session after they log in, and the CLI stores a personal token when you run npx sanity login. Your website, scripts and integrations should use robot tokens instead. The Sanity Content Lake post explains datasets, drafts and perspectives in more detail.
Personal Tokens vs Robot Tokens
| Personal token | Robot token | |
|---|---|---|
| Created by | Logging in (Studio, CLI) | You, in Manage or with the CLI |
| Acts as | Your user, with all your permissions | A robot with the role you assign |
| Lifetime | Around a year by default, shorter with SSO | Until deleted, unless you set an expiry |
| Use it for | Your own CLI work | Websites, CI, scripts, integrations |
| Share it? | Never | Only with the one service that needs it |
A personal token has full access as your user, so it should never end up in an application. Create a dedicated robot token per application instead. Separate tokens mean you can revoke one service without breaking the others, and you can tell from the label what each one is for.
Project and Organisation Tokens
Robot tokens come in two scopes:
- Project tokens: act on a single project. This is what websites and most scripts need.
- Organisation tokens: manage several projects or access organisation-wide apps such as Media Library or Canvas. Reserve these for platform tooling.
Create Least-Privilege Tokens
Each token gets a role. The standard token roles are Viewer (read-only, including drafts) and Editor (read and write), and Enterprise projects can assign custom roles. Pick the smallest role that works:
| Use case | Role | Why |
|---|---|---|
| Reading a private dataset on the server | Viewer | Read-only access is enough |
| Draft previews and Visual Editing | Viewer | Reads drafts, can't change anything |
| Webhook handler that only revalidates pages | None | Use a signing secret instead of a token |
| Form submissions written to Sanity | Editor | Needs to create documents |
| Migration or import scripts | Editor | Needs write access, so create it temporarily |
| CI that deploys the Studio | The narrowest role that can deploy (a custom role on Enterprise) | Avoid giving CI content write access where possible |
You can create tokens in sanity.io/manage under API › Tokens, or from the CLI:
# Read-only token for a website
npx sanity tokens create "Website (production)" --role=viewer
# Write token for an import script, expiring at the end of the project
npx sanity tokens create "Import script" --role=editor --expires-at 2027-01-31
# Review and clean up
npx sanity tokens list
npx sanity tokens delete
The token secret is shown exactly once, so copy it straight into your secrets manager or hosting provider. The --expires-at flag is available in recent CLI versions; check npx sanity tokens create --help if yours doesn't show it, and set expiry in Manage instead. The roles behind each token are covered in managing roles and permissions in Sanity.
Keep Tokens on the Server
The single biggest risk is shipping a token to the browser. Anything bundled into client-side JavaScript is public, and a token found there gives everyone the access that token has. A write token in a bundle means anyone can delete your content.
In Next.js, a variable prefixed with NEXT_PUBLIC_ is inlined into the browser bundle. The project ID and dataset name are not secrets, so they can be public. Tokens must not be:
# .env.local
NEXT_PUBLIC_SANITY_PROJECT_ID=abc123
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_READ_TOKEN=sk... # server only
SANITY_API_WRITE_TOKEN=sk... # server only
Put token-bearing clients in their own module and mark it server-only, so an accidental import from a Client Component fails the build:
npm install server-only
// src/sanity/lib/server-client.ts
import 'server-only'
import { client } from './client'
export const readClient = client.withConfig({
token: process.env.SANITY_API_READ_TOKEN,
useCdn: false,
})
export const writeClient = client.withConfig({
token: process.env.SANITY_API_WRITE_TOKEN,
useCdn: false,
})
// src/sanity/lib/client.ts
import { createClient } from 'next-sanity'
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2025-02-19',
useCdn: true,
})
The public client has no token and is safe anywhere. The token clients can only be imported by Server Components, Route Handlers and Server Actions.
Tokens for Live Preview
defineLive from next-sanity/live takes a serverToken and a browserToken. By default the browser token is only sent to the browser when Draft Mode is on, which is how draft previews and Visual Editing work. Because anyone in Draft Mode can read it, it must be a Viewer token, never an Editor token. Protect the route that enables Draft Mode too, as described in the live preview guide.
Other Places Tokens Leak
- Git history: never commit
.envfiles. Commit a.env.examplewith empty values, and remember that deleting a committed secret doesn't remove it from history. - Logs and error trackers: don't log client configuration objects.
- Code sandboxes and demos: anyone who opens the sandbox can read its code. Use a public dataset without a token.
- Studio environment variables: variables prefixed with
SANITY_STUDIO_are bundled into the Studio, which runs in the browser. Never put a token in one.
Understand CORS
CORS (cross-origin resource sharing) controls which websites can call your project's API from a browser. It's enforced by browsers, not by the API itself, so it has no effect on servers, scripts or curl. A server-side fetch in a Server Component or at build time doesn't need a CORS origin at all.
By default, a project allows http://localhost:3333 and the hostname you deployed the Studio to with sanity deploy. You need to add an origin when:
- You run a local front end on another port, such as
http://localhost:3000, and fetch from the browser. - You host the Studio yourself, for example embedded in a Next.js app.
- A public front end queries Sanity from the browser.
Origins use the format protocol://hostname[:port], for example https://www.example.com or http://localhost:3000.
The Credentials Setting
Each origin has an Allow credentials option. With credentials allowed, a page on that origin can send requests authenticated with the session of a logged-in Sanity user who visits it. That's exactly what a Studio needs, and exactly what a public website doesn't:
| Origin | Allow credentials |
|---|---|
| A self-hosted or embedded Studio | Yes |
| A public website reading a public dataset in the browser | No |
| Local front-end development | No, unless it embeds the Studio |
| A code sandbox | No |
Manage Origins With the CLI
# A public front end that fetches in the browser
npx sanity cors add https://www.example.com --no-credentials
# An embedded Studio on your own domain
npx sanity cors add https://www.example.com/studio --credentials
npx sanity cors list
npx sanity cors delete https://old-site.example.com
You can manage the same list under API › CORS origins in Manage.
Use Wildcards Carefully
A * matches a single segment, such as one subdomain level or a port. https://*.example.com matches https://preview.example.com but not https://a.b.example.com, and http://localhost:* matches any local port.
The dangerous pattern is a wildcard on a shared platform domain with credentials allowed. https://*.vercel.app or https://*.netlify.app would let anyone's site on that platform make authenticated requests as your logged-in editors. For preview deployments, either serve previews from a domain you own (such as https://*.previews.example.com) or anchor the wildcard to a part of the URL only you control, such as your Netlify site name or Vercel team slug. Remove origins you no longer use.
Choose Dataset Visibility Deliberately
A public dataset is fine for most websites, because published content is meant to be seen. Make a dataset private when the content itself is sensitive, such as internal documentation, gated material or unreleased product data. Private datasets need the Growth plan or above, as the Sanity pricing guide explains.
npx sanity datasets visibility set production private
Two caveats:
- Assets are always public: images and files are served from the asset CDN to anyone with the URL, even in a private dataset. URLs contain a content hash, so they're hard to guess, but they aren't secret. If files must be restricted, use Media Library asset visibility or host them elsewhere.
- A token in the browser makes a private dataset public: if your front end needs private content, fetch it on the server and return only what visitors should see.
Accept User Submissions Safely
Contact forms, comments and sign-ups need write access, which means a token. Never write to Sanity from the browser. Put a Server Action or Route Handler in between that validates input and writes with a narrowly scoped token:
// src/app/contact/actions.ts
'use server'
import { writeClient } from '@/sanity/lib/server-client'
export async function submitMessage(formData: FormData) {
const name = String(formData.get('name') ?? '').trim().slice(0, 100)
const email = String(formData.get('email') ?? '').trim().slice(0, 200)
const message = String(formData.get('message') ?? '').trim().slice(0, 5000)
if (!name || !email.includes('@') || !message) {
return { ok: false, error: 'Please fill in every field.' }
}
await writeClient.create({
_type: 'contactMessage',
name,
email,
message,
receivedAt: new Date().toISOString(),
})
return { ok: true }
}
The server decides the document type and fields, so a visitor can't use the endpoint to write anything else. Add rate limiting or a CAPTCHA in front of public forms. The mutation API post covers the write methods in detail.
Secure Your Webhooks
Webhook endpoints are public URLs, so anyone can send them a request. Set a secret on the webhook in Manage and verify the signature before doing anything:
// src/app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { type NextRequest, NextResponse } from 'next/server'
import { parseBody } from 'next-sanity/webhook'
export async function POST(req: NextRequest) {
const secret = process.env.SANITY_REVALIDATE_SECRET
if (!secret) return new Response('Missing secret', { status: 500 })
const { isValidSignature, body } = await parseBody<{ _type: string }>(req, secret)
if (!isValidSignature) return new Response('Invalid signature', { status: 401 })
if (!body?._type) return new Response('Bad request', { status: 400 })
revalidateTag(body._type, 'max')
return NextResponse.json({ revalidated: body._type })
}
A revalidation handler like this doesn't need a Sanity token at all. More patterns are in How to Use Sanity Webhooks.
Rotate, Expire and Audit Tokens
Robot tokens last until you delete them unless you set an expiry. Expiry is set in Manage (30, 60 or 90 days, or a custom date), through the Access API, or with --expires-at in recent CLI versions. Once set, an expiry can be moved but not removed, and an expired token can't be reactivated.
A rotation routine that avoids downtime:
- Create a new token with the same role and a clear label, such as "Website (2026-11)".
- Update the environment variable in your hosting provider and redeploy.
- Wait a day or two so every instance and cached configuration picks up the new token.
- Delete the old token.
If a token leaks, skip the overlap. Treat it as compromised forever, delete it immediately, accept the brief outage and issue a replacement.
A quarterly audit takes ten minutes:
- Run
npx sanity tokens listand delete anything unlabelled, unused or belonging to finished projects. - Check roles: downgrade Editor tokens that only read.
- Review CORS: remove old preview domains and any origin with credentials that doesn't host a Studio.
- Review members: remove people who have left, which is covered in the roles and permissions guide.
FAQ: Sanity API Tokens and CORS
Yes. The project ID and dataset name appear in every API request your site makes, so they aren't secrets. What protects your data is dataset visibility, token permissions and CORS, not hiding the project ID.
Avoid it. A token in browser JavaScript is visible to everyone, so a read token for a private dataset effectively makes that dataset public, drafts included. Fetch on the server and return only the data visitors should see. The exception is the Viewer browser token next-sanity sends during Draft Mode, which only authorised previewers should reach.
No. CORS is a browser mechanism. Requests from Server Components, Route Handlers, build scripts and Node.js aren't subject to it. You only need an origin when JavaScript running in a browser calls the Sanity API directly.
Only for origins that host a Sanity Studio or otherwise need to act as the logged-in user. Public websites and sandboxes should have credentials turned off, so a malicious script on that origin can't make requests with an editor's session.
Delete it immediately in Manage or with npx sanity tokens delete, even if you've already removed it from the code. Then create a replacement with the minimum role needed, update your environment variables and check your dataset history for unexpected changes.
No. Assets are served publicly from the asset CDN to anyone who has the URL. The URLs include a content hash, so they're impractical to guess, but if files must be restricted you need Media Library asset visibility or a different host.
Conclusion
Securing a Sanity project comes down to a few habits. Give every application its own robot token with the least privileged role, keep tokens in server-only environment variables behind a server-only module, and route any writes from visitors through server code that validates input. Treat CORS as a browser allowlist: add only the origins that genuinely fetch from the browser, and allow credentials only where a Studio runs.
Then make it routine. Sign your webhooks, set expiry dates on tokens for temporary jobs, rotate long-lived tokens on a schedule and audit tokens, origins and members every few months. None of it takes long, and it closes the gaps that cause real incidents.


