Headless CMS Authentication: API Tokens Done Right

Public reads, token-gated writes, and the leaks in between

July 3, 2026 · 13 min read
Headless CMS Authentication: API Tokens Done Right

A surprising number of headless CMS deployments ship their admin write token to every visitor's browser. The pattern is always the same: a developer puts the token in a NEXT_PUBLIC_ env var, the static site builds, and now anyone who opens DevTools can create, edit, or delete content. Your headless CMS API token strategy decides whether that happens to you.

This guide covers how token auth should work in a headless setup: which endpoints need tokens at all, which token type to pick, how Laravel Sanctum handles the server side, where tokens leak in practice, and how to verify webhooks coming back the other way. Code examples use curl and a Laravel-backed API, but the patterns apply to any headless CMS.

TL;DR: Published content should be readable without any token. Write and admin endpoints need a bearer token that never touches client-side code. Give build pipelines read-only credentials, rotate anything long-lived, and verify inbound webhooks with HMAC-SHA256. Most token incidents are not clever attacks — they're write tokens sitting in public JS bundles or git history.


The Two Access Patterns Every Headless CMS Has

A headless CMS API splits into two halves with completely different security needs: public read endpoints for published content, and authenticated endpoints for everything that changes state. Treat them differently from day one — most token mistakes come from blurring this line.

Public read: no token needed

Published posts, pages, categories, and menus are public by definition. They end up rendered on a public website anyway, so gating them behind a token adds friction without adding security. Anyone who wants the content can scrape your rendered HTML.

UnfoldCMS follows this split in its REST API: everything under /api/v1/* that returns published content — posts, pages, categories, search, menus, public settings — is readable without authentication:

# No token. Works from anywhere, including the browser.
curl https://example.com/api/v1/posts?per_page=10

This matters more than it looks. If your frontend only reads published content, it needs zero secrets. A Nuxt or SvelteKit site fetching posts at build time can run with no credentials at all — which means there's nothing to leak. The Nuxt integration guide builds an entire frontend this way.

Authenticated write: token required

Creating posts, editing pages, managing users, changing settings — anything that mutates state — requires a bearer token. These endpoints should also enforce authorization on top of authentication: a valid token belonging to an author shouldn't be able to delete another user's content. Roles and permissions handle that layer; the token only proves who's calling.

The rule of thumb: if the endpoint changes anything, or returns unpublished/private data, it needs a token. Everything else shouldn't.


Which Token Type Should You Use?

Three token styles cover almost every headless CMS integration: long-lived personal access tokens, scoped tokens, and short-lived JWTs. Each trades convenience against blast radius.

Token type Lifetime Blast radius if leaked Best for
Personal access token Months/years Everything the user can do Quick scripts, personal tooling
Scoped token Months, but limited Only the granted abilities CI pipelines, integrations
Short-lived JWT Minutes/hours Small window, then useless Service-to-service, SSO flows

Personal access tokens

The simplest option: generate once, paste into a script, works until revoked. The problem is that a personal access token usually inherits all the permissions of the user who created it. Leak an admin's PAT and the attacker is an admin. Fine for a one-off migration script on your laptop; risky as the permanent credential of a deployed system.

Scoped tokens

Same mechanics, but the token carries an explicit list of what it can do — read-only, posts:write, media:upload. A leaked read-only token lets an attacker read content that was mostly public anyway. That's a dramatically better failure mode than full admin access, and it costs you one extra parameter at creation time.

Short-lived JWTs

JWTs expire on their own, which removes the "forgotten token from 2024 still works" problem. The cost is infrastructure: something has to issue them, clients have to refresh them, and clock skew becomes your problem. For a CMS integration where the caller is a build server hitting the API twice a day, a scoped long-lived token plus scheduled rotation is usually simpler than running a JWT issuance flow. JWTs earn their complexity when you have many short sessions or federation between services.

One more subtlety: JWTs are stateless, so revoking one before expiry requires a denylist — which reintroduces the database lookup that statelessness was supposed to avoid. For most CMS workloads, database-backed opaque tokens (the Sanctum model below) are the honest choice.


Laravel Sanctum: A Concrete Token Pattern

Sanctum is Laravel's first-party token system and a good reference implementation even if you're not on Laravel: hashed-at-rest opaque tokens, per-token abilities, instant revocation. UnfoldCMS uses Sanctum bearer tokens for its authenticated /api/v1 write and admin endpoints, so the examples below are exactly what talking to it looks like.

Creating a token

Server-side, a token is one call. The second argument is the abilities list — Sanctum's name for scopes:

// Full-access token (avoid for anything deployed)
$token = $user->createToken('local-script')->plainTextToken;

// Scoped token: this one can only read
$token = $user->createToken('netlify-build', ['read'])->plainTextToken;

The plain-text value is shown once. Sanctum stores only a SHA-256 hash in the database, so even a database dump doesn't expose usable tokens. If you lose the plain value, you create a new token — there's no "show me again."

Using it: the Bearer header

Every authenticated request carries the token in the Authorization header:

curl -X POST https://example.com/api/v1/admin/posts \
  -H "Authorization: Bearer 1|x7Kp9mQ2..." \
  -H "Content-Type: application/json" \
  -d '{"title": "New post", "status": "draft"}'

Never put tokens in query strings (?token=...). URLs end up in server logs, browser history, analytics, and Referer headers — four leak paths for the price of one.

Checking abilities

On the server, routes verify the token has the right ability before doing anything:

if (! $request->user()->tokenCan('posts:write')) {
    abort(403);
}

A token with only read hits this wall on every write route. That check is what makes scoped tokens worth issuing — without enforcement, scopes are decoration.


Where Tokens Actually Leak

Token leaks are rarely exotic. In practice they come from four boring places, and you can audit all of them in an afternoon.

1. Committed to git

A token pasted into a config file "just to test" gets committed, pushed, and lives in history forever — even after you delete the line. GitGuardian's 2024 report found roughly 12.8 million secrets exposed in public GitHub commits in one year. If a token ever touched a commit, treat it as burned: revoke it and issue a new one. Don't bother rewriting history — scrapers saw it within minutes.

Cheap prevention: keep tokens in .env, keep .env in .gitignore, and run a secret scanner (gitleaks, trufflehog) as a pre-commit hook.

2. Shipped in client-side JavaScript

This is the classic headless CMS mistake. A developer building a static frontend needs the CMS API in the browser or at build time, so the token goes into a public env var:

# .env.local — this token is now in your public JS bundle
NEXT_PUBLIC_CMS_TOKEN=1|x7Kp9mQ2...

In Next.js, anything prefixed NEXT_PUBLIC_ is inlined into the client bundle at build time. Vite does the same with VITE_, Nuxt with anything exposed via runtimeConfig.public. The token is now a string inside a .js file that every visitor downloads. View source, search for Bearer, done — no attack required.

The fix is structural, not cosmetic:

  • Reading published content? Use the public endpoints. No token in the frontend at all.
  • Need authenticated calls? Make them from server-side code only — API routes, server components, serverless functions — where env vars stay on the server.

If a write token appears anywhere in dist/ or .next/static/, the architecture is wrong.

3. CI logs and build output

Build pipelines echo commands. A curl -H "Authorization: Bearer $TOKEN" with set -x enabled prints the token into a log that half the company can read. Use your CI's secret masking (GitHub Actions and Netlify both redact registered secrets from logs) and never echo credentials while debugging.

4. Shared through chat and docs

Tokens pasted into Slack, Notion, or a ticket outlive the conversation — anyone joining the channel later inherits the secret. Hand tokens to people via one-time-link secret sharers, then revoke after first use.


Least Privilege: Read Tokens for Builds, Write Tokens for Servers

Every token should carry the minimum access that the consumer needs — and most consumers need much less than you'd think.

The clearest example is a static-site build pipeline. When Netlify or Vercel builds your frontend, it fetches published posts and pages — that's it. It never writes. So the build either uses the public read endpoints with no token at all, or, if it needs unpublished/private data, a read-only token. If that token leaks through a build log, the attacker can read content. Annoying, not catastrophic. The Netlify deployment guide is a full walkthrough of this pattern.

Write tokens belong in exactly one place: server-side environments you control. A cron job that imports content, a backend service that syncs products, an editorial tool running on your own infrastructure. The decision table is short:

  1. Browser code → no token, public endpoints only.
  2. Build pipeline → no token or read-only token.
  3. Server-side service → scoped write token, one per service.
  4. Human running one-off scripts → personal token, revoked when done.

"One token per service" matters more than it looks. When netlify-build, content-importer, and slack-bot each have their own token, you can revoke one without breaking the others — and your access logs tell you which integration made which call.


Rotation and Revocation

Long-lived tokens accumulate risk: every month a token exists is another month of logs, backups, and laptops it might be sitting in. Two habits keep this in check.

Rotate on a schedule. Quarterly is a reasonable default for service tokens. The mechanics: create the new token, deploy it to the consumer, confirm it works, revoke the old one. Because Sanctum-style tokens are database rows, revocation is immediate — delete the row and the very next request with the old token gets a 401:

// Revoke one token by name
$user->tokens()->where('name', 'netlify-build')->delete();

Revoke on events, not just schedules. A teammate leaves, a laptop is stolen, a token shows up in a commit — revoke first, investigate second. A new token takes thirty seconds to mint; an incident report takes considerably longer. This is the strongest practical argument for database-backed tokens over plain JWTs: there's a kill switch, and it works instantly.

Keep an inventory. If you can't list every active token, who holds it, and what it can do, you can't rotate it — and tokens you've forgotten about are precisely the ones that leak.


Rate Limiting: The Other Half of API Auth

Authentication decides who can call; rate limiting decides how hard. Even public read endpoints need limits — an unthrottled list endpoint is a free DoS target and a gift to scrapers.

A sane baseline: generous limits on public reads (a real frontend rarely needs more than 60 requests/minute per IP), tighter per-token limits on writes, and aggressive throttling on login and token endpoints, where credential-stuffing happens. In Laravel this is middleware, not custom code:

Route::middleware(['auth:sanctum', 'throttle:60,1'])
    ->post('/api/v1/admin/posts', ...);

Return 429 with a Retry-After header so well-behaved clients back off instead of hammering. Rate limits also act as a leak alarm: a read-only token suddenly making 5,000 requests an hour is worth a page to whoever's on call.


Webhook Signatures: When the CMS Calls You

Tokens solve "prove you're allowed to call the CMS." Webhooks are the inverse problem: the CMS calls your endpoint — on publish, on update — and you need to prove the request really came from the CMS and not from anyone who found your webhook URL.

The standard answer is an HMAC-SHA256 signature. The CMS and your receiver share a secret. For every delivery, the CMS computes HMAC-SHA256(secret, raw_body) and sends the result in a header. Your receiver recomputes it and compares. UnfoldCMS signs its outgoing webhooks exactly this way, so a receiver looks like:

$signature = hash_hmac('sha256', $request->getContent(), $secret);

if (! hash_equals($signature, $request->header('X-Signature'))) {
    abort(401);
}

Two details that bite people:

  • Use a constant-time comparison (hash_equals, not ===). Naive string comparison leaks timing information that lets an attacker reconstruct a valid signature byte by byte.
  • Sign the raw request body, before any JSON parsing. Re-encoding parsed JSON can reorder keys or change whitespace, and the signature won't match.

An unsigned webhook endpoint is an open door: anyone who guesses the URL can trigger your rebuild loop or poison your cache. Since the most common webhook consumer is a deploy hook, the frontend rebuild guide covers wiring this up end to end.


The Practical Security Checklist

Run through this before your headless CMS setup touches production:

  1. Public content served from public endpoints — no token in frontend code.
  2. No CMS token in any NEXT_PUBLIC_ / VITE_ / client-exposed env var.
  3. Write tokens exist only in server-side env vars, never in git.
  4. Build pipelines use read-only credentials (or none).
  5. One token per service, named after the service.
  6. Secret scanner in pre-commit or CI (gitleaks, trufflehog).
  7. Tokens stored hashed at rest on the CMS side.
  8. Rotation schedule exists and someone owns it.
  9. Revocation tested — it takes effect immediately.
  10. Rate limits on every endpoint, strictest on auth routes.
  11. Webhook receivers verify HMAC-SHA256 with hash_equals.
  12. Token inventory documented: name, holder, abilities, created date.

Twelve lines, maybe a day of work — and it closes the leak paths behind nearly every "our CMS got defaced" story.


FAQ

Does a headless CMS need authentication for public content?

No. Published posts, pages, and menus end up on a public website anyway, so token-gating them adds friction without security. Save authentication for write endpoints and unpublished data.

Is it safe to put a CMS API token in a Next.js env var?

Only in server-only vars. Anything prefixed NEXT_PUBLIC_ is compiled into the client JavaScript bundle and visible to every visitor. Call the CMS from API routes or server components instead.

Should I use JWTs or database tokens for a headless CMS?

Database-backed opaque tokens win for most CMS integrations: instant revocation, hashed storage, no refresh-flow plumbing. JWTs make sense for high-volume service-to-service auth where the issuance infrastructure already exists.

How do I verify a webhook came from my CMS?

Compute HMAC-SHA256 over the raw request body with your shared secret and compare it to the signature header using a constant-time comparison. Reject anything that doesn't match.


If you're evaluating this from the self-hosted side: UnfoldCMS is a self-hosted Laravel CMS with the split described here built in — public /api/v1 reads, Sanctum bearer tokens for writes, roles and permissions on top, and HMAC-signed outgoing webhooks. See how the API works on the features page or try the endpoints against the live demo.


Sources: Laravel Sanctum documentation (token abilities and hashing), GitGuardian State of Secrets Sprawl 2024 (12.8M exposed secrets), Next.js documentation on NEXT_PUBLIC_ env var inlining, OWASP guidance on timing-safe comparison and API rate limiting.

Related: trigger frontend rebuilds with webhooks, UnfoldCMS + Nuxt integration, UnfoldCMS + SvelteKit integration.

Free & Open Source

Own your CMS. No subscriptions.

Unfold CMS is free to download and self-host. Built on Laravel + React, full source code included.

Share this post:

Discussion

Comments (0)

Leave a Comment

Please log in to leave a comment.

Don't have an account? Register here

No comments yet. Be the first to share your thoughts!

Keep Reading

Related Posts

Back to all posts