Headless CMS API Auth: The Two-Surface Model

August 2, 2026 · 8 min read
Headless CMS API Auth: The Two-Surface Model

The fastest way to get a headless CMS wrong is authentication. Not the login page, the API: which endpoints are public, which need tokens, where those tokens live, and what happens when one leaks. I've seen a production admin token committed to a frontend repo, a "temporary" shared key in six developers' dotfiles, and a public write endpoint discovered by a spam bot within a week. All three were architecture problems wearing a carelessness costume.

This guide covers how headless CMS authentication should work, using patterns that apply across platforms, with the specific mechanics from ours where an example needs to be concrete.

TL;DR: Split your content API into public reads and authenticated writes. Published content should be readable with no token at all, which keeps secrets out of frontends and lets CDNs cache freely. Writes and drafts need scoped tokens: short-lived where possible, stored server-side always, never in a browser bundle or mobile binary. Rate-limit both surfaces. Most CMS API breaches are leaked static tokens with too much power, so the design goal is tokens that are boring to steal.


The Two-Surface Model

Every headless CMS API serves two audiences with opposite security needs, and the architecture should say so out loud.

The read surface delivers published content: post lists, pages, menus, search. This data is public by definition (it's your website), so requiring auth for it buys nothing and costs plenty. Tokenless reads mean your Next.js build, your mobile app, and your CDN all fetch without holding secrets, and a leaked frontend bundle leaks nothing. In UnfoldCMS, the /api/v1 content endpoints work exactly this way: public read, no token, cacheable by anything.

# public read: no credentials anywhere
curl https://yoursite.com/api/v1/posts

The write surface creates and edits content, reads drafts, changes settings. Everything here demands authentication, and the token that authorizes it should be treated like a database password, because functionally it is one.

If your CMS requires a token even for public reads (several SaaS platforms do, for metering), you've inherited a secrets-distribution problem in every client. It's manageable server-side; it's a standing risk in mobile binaries, where any shipped key should be assumed extracted.


Token Types, and When Each Fits

Static API tokens are what most CMSs issue: a long random string tied to a user or service account. Laravel-based CMSs (ours included) typically issue them through Sanctum, which stores only a hash server-side and lets you scope abilities per token, so a "content-writer" token can create posts but not touch settings, and an admin-ability token is a separate, rarer thing.

Static tokens are fine when they're scoped, revocable, and held server-side. The standard failure is one unscoped admin token reused everywhere because it "already worked."

# authenticated write: token from server-side env, never a bundle
curl -X POST https://yoursite.com/api/v1/posts \
  -H "Authorization: Bearer $CMS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title": "Launch notes", "body": "..."}'

Short-lived JWTs improve on static tokens for user-facing write flows: your backend authenticates the user, mints a token with an expiry measured in minutes, and a stolen token dies before it travels. The cost is infrastructure: something must issue, refresh, and validate them. Worth it the moment end users (not just your build pipeline) write through the API.

OAuth flows belong where third parties access your CMS on users' behalf: an integration marketplace, a partner app. If you're only wiring your own frontend to your own CMS, OAuth is machinery without a mission.


Where Tokens Live (the Part That Actually Breaches)

The pattern behind most real incidents is location, not cryptography.

Browser JavaScript: never. Anything in the bundle is public; "it's minified" is not a control. Frontends that must write go through your own thin backend, which holds the token and enforces its own rules.

Mobile binaries: same rule, sterner. App bundles get decompiled routinely, which is why the React Native content pattern keeps public reads direct and routes anything credentialed through a backend you control.

CI and build pipelines: the legitimate home for static read-tokens (when your CMS meters reads) and deploy-hook secrets. Use the platform's secret store, not repository files, and rotate when someone with access leaves.

Server environments: .env files with tight filesystem permissions, or a secrets manager once the team is big enough that "who has prod env access" needs an answer beyond a shrug.

And webhooks flow the other direction but carry the same lesson: a CMS calling your systems should sign its payloads (UnfoldCMS signs with HMAC), and your receiver should verify with a constant-time comparison before trusting a byte. An unsigned webhook endpoint is an invitation to forge publish events.


Rate Limits Complete the Story

Authentication without throttling is half a defense. Public read endpoints face scrapers; write endpoints face credential-stuffing and brute force. Sensible defaults look like our shipped ones: 60 requests per minute on the API surface, 5 per minute on login attempts, tighter caps on uploads. The numbers matter less than their existence and their visibility in error responses (429 with a Retry-After beats silent drops). If your CMS exposes no rate limiting, put a proxy in front that does; Cloudflare's free tier covers the basics.


When a Token Leaks: The First Hour

Design assumes prevention; operations assumes the leak already happened. Here's the sequence for the day a CMS token shows up somewhere it shouldn't, whether that's a public repo, a log aggregator, or a departing contractor's laptop.

Revoke before you investigate. The instinct is to first figure out what the token could access; resist it, because every investigating minute is an exposed minute. Scoped tokens make this cheap (revoke one consumer's token, one consumer breaks), which is the operational argument for the scoping discipline above. If it was the shared super-token, revoke anyway and accept the outage; a broken deploy pipeline beats an attacker with admin writes.

Then read your access logs backward from now to the leak's earliest possible moment. You're looking for writes you don't recognize: new posts (spam injection), edited posts (SEO link injection is the common motive), new users, changed settings, uploaded files. CMS compromises are usually loud in the content and quiet in the logs, so diff recent content against backups if your log retention is thin.

Rotate the blast radius, not just the token. If the leaked token could read settings, treat every secret visible in those settings as leaked too: SMTP credentials, webhook secrets, API keys to downstream services. This is also the argument for keeping such secrets out of CMS-readable settings where possible.

Close with the boring fix: how did it leak, and which control would have caught it? Committed to a repo means you need pre-commit secret scanning (gitleaks in CI takes an hour to add). Logged by middleware means your logger needs a redaction list. The leak is an incident; the missing control is the actual finding.


FAQ

Should CMS read endpoints require an API key?

For published public content, no. Requiring keys for reads spreads secrets into every client and breaks CDN caching, in exchange for metering you can get from logs. Keys on reads make sense only for private or pre-release content.

What is the difference between an API token and a JWT?

A static API token is an opaque string the server looks up (usually as a hash) on every request; it lives until revoked. A JWT carries signed claims and an expiry inside itself, enabling short lifetimes and stateless validation. Static tokens suit service-to-service links; JWTs suit user-facing sessions.

How do I rotate CMS API tokens safely?

Issue the replacement first, deploy it to every consumer, verify traffic on the new token, then revoke the old one. Scoped tokens make this painless because each consumer holds its own; a single shared super-token makes rotation a scheduled outage.

How should I verify CMS webhooks?

Compute the HMAC of the raw payload with your shared secret and compare it to the signature header using a constant-time function (hash_equals in PHP, crypto.timingSafeEqual in Node). Reject on mismatch before parsing anything.

Are API keys safe in environment variables?

Safer than code, sufficient for most teams: env files stay out of version control and off browsers. Graduate to a secrets manager (Vault, cloud-native options) when team size makes "who can read prod env" a governance question rather than a trust one.

Should each environment have its own CMS tokens?

Always. Staging holding production tokens is how test scripts write to live sites, and shared tokens make log attribution useless. Separate tokens per environment and per consumer cost nothing and turn every incident question from archaeology into a lookup.


Methodology

Patterns reflect standard practice documented across Laravel Sanctum, Auth0's token guidance, and OWASP's API Security Top 10 as of July 2026. UnfoldCMS specifics (public-read /api/v1, Sanctum token abilities, HMAC-signed webhooks, shipped rate limits) reflect the live product; endpoints are documented at unfoldcms.com/docs. We build UnfoldCMS, and the two-surface model above is as much our design rationale as our advice.

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