A headless CMS serves content via API to multiple consumers: a public website, a mobile app, a preview server for editors, and perhaps third-party integrations. Each consumer has a different permission level — the public website can only read published content, editors can read drafts, and the integration service can write content. Using a single API key for all of this is the common but wrong approach. Audience-scoped tokens and a clear permission model make the access control explicit, auditable, and revocable per consumer.
The audience claim for multi-consumer APIs
The JWT aud (audience) claim specifies who the token is intended for. When your CMS API validates a token, it should verify that aud includes its own identifier. This prevents a token issued for one service from being used against another — even if both services accept tokens from the same issuer.
// Issue tokens with specific audience claims
import { SignJWT, importPKCS8 } from 'jose';
async function issueCMSToken(clientId: string, spaceId: string, permissions: string[]) {
const privateKey = await importPKCS8(process.env.CMS_SIGNING_KEY, 'ES256');
return new SignJWT({
sub: clientId,
space: spaceId, // the CMS space being accessed
permissions, // ['content:read', 'content:write', 'drafts:read']
})
.setProtectedHeader({ alg: 'ES256', kid: 'cms-key-2021' })
.setIssuedAt()
.setExpirationTime('1h')
.setIssuer('https://auth.yourapp.com')
.setAudience(['https://api.cms.yourapp.com']) // aud: the CMS API
.sign(privateKey);
}
// CMS API: validate token with audience check
async function validateCMSToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.yourapp.com',
audience: 'https://api.cms.yourapp.com', // rejects tokens for other audiences
algorithms: ['ES256'],
});
return payload;
}
Content-level RBAC
CMS permissions need to be more granular than "can read" vs "can write." A typical permission model for a headless CMS includes content type permissions (can this client access blog posts? product data?), publication status permissions (published only vs including drafts), and write permissions (create, update, delete, publish).
// Permission model for CMS API tokens
type CMSPermission =
| 'content:read' // read published content
| 'content:write' // create and update content
| 'content:publish' // change publication status
| 'content:delete'
| 'drafts:read' // read unpublished drafts
| 'assets:read'
| 'assets:write'
| 'webhooks:manage';
// Per-content-type permissions (more granular)
// e.g., ['content:read:blog_post', 'content:read:product']
// Authorization middleware for CMS routes
function requireCMSPermission(permission: CMSPermission) {
return (req, res, next) => {
const tokenPermissions: string[] = req.user.permissions || [];
if (!tokenPermissions.includes(permission) &&
!tokenPermissions.includes('content:*')) { // wildcard admin permission
return res.status(403).json({
error: 'insufficient_permissions',
required: permission,
granted: tokenPermissions,
});
}
next();
};
}
// Apply to routes
app.get('/api/entries',
requireCMSPermission('content:read'),
listEntries
);
app.get('/api/entries/:id/draft',
requireCMSPermission('drafts:read'), // separate permission for drafts
getDraftEntry
);
app.post('/api/entries/:id/publish',
requireCMSPermission('content:publish'),
publishEntry
);
Preview tokens
A content editor needs to preview unpublished content before it goes live. The public website uses CDN-cached responses and cannot dynamically authenticate editors. The solution is a short-lived, single-use preview token that the CMS admin interface generates and passes to the preview URL.
// Server-side: generate preview token
async function generatePreviewToken(contentId: string, editorUserId: string) {
const token = crypto.randomBytes(24).toString('base64url');
const hash = createHash('sha256').update(token).digest('hex');
await redis.setex(`preview:${hash}`, 3600, JSON.stringify({
contentId,
editorUserId,
generatedAt: Date.now(),
}));
// Return URL with token
return `https://preview.yoursite.com/${contentId}?preview_token=${token}`;
}
// Preview server: validate preview token
app.get('/api/preview-content/:id', async (req, res) => {
const { preview_token } = req.query;
if (!preview_token) {
return res.status(401).json({ error: 'preview_token required' });
}
const hash = createHash('sha256').update(preview_token as string).digest('hex');
const data = await redis.get(`preview:${hash}`);
if (!data) {
return res.status(401).json({ error: 'Invalid or expired preview token' });
}
const { contentId } = JSON.parse(data);
if (contentId !== req.params.id) {
return res.status(403).json({ error: 'Token not valid for this content' });
}
// Fetch including drafts
const content = await fetchContentIncludingDrafts(req.params.id);
res.json(content);
});
Public vs authenticated endpoints
Most headless CMS APIs have both public (unauthenticated) and private (authenticated) endpoints. The public read API serves published content at high volume with CDN caching. The management API requires authentication for all operations.
// Route structure: public vs authenticated
// Public delivery API — no auth required, CDN-cacheable
app.get('/api/v1/spaces/:spaceId/entries', async (req, res) => {
// Only return published content
const entries = await getPublishedEntries(req.params.spaceId, req.query);
// Set Cache-Control for CDN caching
res.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=60');
res.json(entries);
});
// Content Management API — requires valid JWT with appropriate permissions
app.use('/api/v1/spaces/:spaceId/management', authenticateCMSToken);
app.get('/api/v1/spaces/:spaceId/management/entries',
requireCMSPermission('content:read'),
async (req, res) => {
// Returns all entries including drafts for this space
// Response should NOT be cached by CDN
res.set('Cache-Control', 'no-store');
const entries = await getAllEntries(req.params.spaceId, req.query);
res.json(entries);
}
);
CDN edge authentication
For gated content delivery — premium content that requires authentication before serving — CDN edge auth allows the CDN to verify access before caching or forwarding to origin. Cloudflare Workers, AWS CloudFront with Lambda@Edge, and Fastly Compute all support this pattern.
// Cloudflare Worker: validate CMS token at edge for gated content
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
// Public content: pass through (CDN caches normally)
if (url.pathname.startsWith('/public/')) {
return fetch(request);
}
// Gated content: validate token at edge
const authHeader = request.headers.get('Authorization');
const previewToken = url.searchParams.get('preview_token');
if (previewToken) {
// Forward preview token validation to origin
// (origin has the Redis lookup)
return fetch(request);
}
if (!authHeader?.startsWith('Bearer ')) {
return new Response('Unauthorized', { status: 401 });
}
try {
const JWKS = createRemoteJWKSet(new URL(env.JWKS_URL));
const { payload } = await jwtVerify(
authHeader.slice(7), JWKS,
{ audience: 'https://api.cms.yourapp.com' }
);
// Check space access
if (payload.space !== url.pathname.split('/')[2]) {
return new Response('Forbidden', { status: 403 });
}
// Strip auth header before forwarding (don't expose to origin log)
const modReq = new Request(request, {
headers: { ...Object.fromEntries(request.headers), 'X-CMS-User': payload.sub },
});
modReq.headers.delete('Authorization');
return fetch(modReq);
} catch {
return new Response('Unauthorized', { status: 401 });
}
}
};
The combination of audience-scoped tokens, content-level RBAC, and separate public/private endpoint routing gives a headless CMS API the same security posture as a traditional CMS while maintaining the performance benefits of API-first architecture and CDN caching. The common mistake is treating the CMS content API as a public API that just requires an API key — API keys for CDN delivery should be read-only, scoped to a specific space, and rotatable independently from the management API credentials.