Auth for headless CMS APIs: audience-scoped tokens and content permissions

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);
});
Preview tokens should not be persisted in the URL after initial use — they will appear in access logs, browser history, and analytics systems. After the preview page loads, the token should be exchanged for a short-lived session cookie that authorizes subsequent requests for that preview session. The URL-visible token is single-use; the session cookie carries the authorization for page assets and API calls.

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.

← Back to blog Try Bastionary free →