Content governance in a headless CMS lives in code, not in admin settings. This guide maps Strapi 5's RBAC, Review Workflows, Document Service middlewares, and audit logging into a governance framework you can version-control, with a publish middleware, a custom audit table, and locale-scoped permissions you can lift into your own project.
An Editor clicks Publish on a half-translated product page. In a headless setup that click pushes the entry through the Content API to a Next.js site, a mobile app, a kiosk fleet, and a partner's syndication feed at once, and none of those consumers share a preview layer.
Governance in a headless Content Management System (CMS) therefore belongs to developers as much as to editors. Who can publish what, and when, only holds when it is written as permission scopes, Document Service middlewares, and webhook pipelines.
In brief:
- Strapi 5 enforces governance at three layers: Admin Panel RBAC, Document Service middlewares, and Content API
statusdefaults. - Custom roles, field-level permissions, and code-registered conditions are free on every plan; Review Workflows and Audit Logs require Enterprise.
- Document Service middlewares, not lifecycle hooks, enforce publish rules in Strapi 5.
- Built-in Audit Logs skip REST and GraphQL mutations, so API-driven changes need custom logging.
Together, these layers define who can act, which transitions are allowed, and where each change is recorded.
What Content Governance Means in a Headless Architecture
Content governance is the system of policies, roles, and automation that controls who can create, review, publish, and retire content across every channel that consumes it. Content Science Review calls it "policies, roles, standards, workflows, and decision frameworks" that keep content consistent and reliable. For a developer the operational version is narrower: which permission scopes exist, which state transitions are allowed, and what gets recorded when a transition happens.
Traditional CMS governance is page-level. Content, templates, and rendering live in one system, so there's one place to publish, one place to preview, and one output to check. A traditional CMS owns the presentation layer; headless leaves each channel team to build its own.
Headless governance moves to the content-model level. Once several teams and channels share one content model, ownership of Content-Types, field definitions, and publishing rules has to be assigned to someone, or the model drifts. Schema changes carry the same multiplier. Strapi's deployment docs warn that "starting Strapi with a modified content-types schema alters the database, and removing a content-type drops its table," and in a headless stack that change reaches every API consumer in the same deploy.
Why Developers Own the Governance Layer
In a headless CMS, governance is infrastructure code. You define the Content-Types, the permission scopes each role gets on them, the middlewares that sit in front of publish, and the token policies that decide what the public Content API returns. Roles can be adjusted in the Admin Panel, but the boundaries come from your repository.
GitHub issue #24860 records a high-severity bug in Strapi 5.30.0 where the REST API ignored an explicit publishedAt: null and auto-published entries, so any continuous integration (CI) pipeline or migration script expecting drafts was publishing straight to the public API. Draft leakage is the second failure mode, because the Document Service API returns draft versions by default while REST and GraphQL default to published.
A service that copies a Document Service query into a public endpoint without setting status hands out unpublished content. Ungated locales are the third: publish() without a locale parameter publishes only the default locale, and nothing built in stops a half-finished fr version from going live on its own.
Role-Based Access Control as the Foundation
Governance starts with least privilege — publish rights scoped to exactly the roles that need them, nothing broader.
Mapping Organizational Roles to CMS Permissions
Role-based access control (RBAC) in a headless CMS starts from least privilege. National Institute of Standards and Technology (NIST) SP 800-53 control AC-6 asks you to allow "only authorized accesses for users (or processes acting on behalf of users) that are necessary to accomplish assigned organizational tasks." Applied to content operations, publish rights go only to roles that actually need them.
Strapi 5 ships three default administrator roles: Author (create and manage own content), Editor (create, manage, and publish any content), and Super Admin (everything). A working matrix usually splits further. Authors create and edit their own entries under the must-be-the-creator condition. A custom Editor variant updates anything but has the publish action removed. A custom Publisher role holds the publish action and little else, while Admins own roles, webhooks, and locales. API Consumers aren't admin roles at all, just Content API tokens scoped per endpoint.
Scope permissions at the Content-Type level first: for each type, Strapi's RBAC grants or denies create, read, update, delete, and publish. Then expand the type in the role editor to restrict individual fields. Field-level scoping is what lets a Marketing role rewrite a description without touching price. Reach for it only when one team edits part of a record another team owns; otherwise type-level is enough.
Implementing Custom Roles in Strapi
Custom roles and an unlimited role count are free across Community, Growth, and Enterprise, and have been since 4.8, per the pricing page. Configure them under Settings > Administration panel > Roles, where permissions are set per Content-Type, per field, and, with internationalization (i18n) enabled, per locale.
The UI ships two conditions, "The administrator must be the creator" and "The administrator must have the same role as the creator," and conditions only apply to permissions the role already holds. Anything richer lives in code, as the RBAC configuration guide describes. Register conditions in bootstrap() in src/index.js, following the server functions docs:
// src/index.js
module.exports = {
async bootstrap({ strapi }) {
await strapi.admin.services.permission.conditionProvider.registerMany([
{
displayName: 'Billing amount under 10K',
name: 'billing-amount-under-10k',
plugin: 'admin',
handler: { amount: { $lt: 10000 } },
},
]);
},
};Full role provisioning as code is a documented gap: no stable Admin REST endpoint for role CRUD, no declarative role config.
Designing Editorial Workflows That Scale
Two states cover a solo blog; a team with approval requirements needs intermediate stages enforced at the API layer, not in Slack.
From Draft to Published: Modeling Content Lifecycle States
Every headless CMS workflow starts as a two-state machine. Draft and Publish gives you one per document and per locale: a draft with publishedAt: null and a published version with an ISO timestamp. Two states cover a solo blog. They don't cover a team where the person who writes should never be the person who approves, or where legal signs off before anything localized ships. In practice, Draft to Published turns into looks-good-to-me approvals in Slack that nobody can reconstruct six months later.
Review Workflows add the intermediate stages. The feature is Enterprise-only and configured by a Super Admin under Settings > Global Settings > Review Workflows. The default workflow ships four stages (To do, In Progress, Ready to review, Reviewed), all renameable, re-orderable, and deletable, and a workflow can be associated with one or more Content-Types. Moving an entry is permission-gated at both ends: "you must have permission to move content from the current stage and permission to move content to the target stage." Each stage carries two role lists, described in the compliance pipeline walkthrough as roles that can change from this stage and roles that can move content to this stage.
Nothing in the documentation describes a mechanism that blocks Publish based on stage. Enforcement is your job.
Enforcing Workflow Rules at the API Layer
Every publish path (Admin Panel, CI script, or migration) converges on the Document Service, so the editorial workflow API is where the rules have to bind. In Strapi 5, Document Service middlewares are the recommended place for this logic rather than lifecycle hooks. The Strapi engineering blog explains why: under Draft and Publish, "creating a new published document will result in afterCreate and beforeCreate hooks being called twice," and update({ status: 'published' }) fires create, update, and delete hooks multiple times per locale. A middleware intercepts the publish action with the full context: contentType.uid, action, and params including documentId, locale, and status.
Register it in register() and inspect the draft before letting publish through:
// src/index.js
module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
const isArticlePublish =
context.action === 'publish' &&
context.contentType.uid === 'api::article.article';
if (!isArticlePublish) {
return next();
}
const draft = await strapi.documents(context.contentType.uid).findOne({
documentId: context.params.documentId,
locale: context.params.locale,
status: 'draft',
populate: ['seo', 'cover'],
});
const missing = [];
if (!draft?.seo?.metaDescription) missing.push('seo.metaDescription');
if (!draft?.cover) missing.push('cover');
if (missing.length > 0) {
throw new Error(`Publish blocked: missing ${missing.join(', ')}`);
}
return next();
});
},
};The same shape handles review-stage checks, locale coverage, and linked-asset validation. There's no official example of throwing from a middleware to block publish, so validate this against your own test harness before shipping it. And publish(), unpublish(), and discardDraft() throw on Content-Types without Draft and Publish, so a shared middleware should check contentType.options.draftAndPublish first.
For gates outside Strapi, webhooks carry the events. entry.publish fires only when the Draft and Publish feature is enabled on the content-type, not on every plan. review-workflows.updateEntryStage (Enterprise) includes workflow.stages.from and workflow.stages.to with stage id and name, so an external service can react to a move into Ready to review by opening a legal ticket or starting a GitHub Actions check. Strapi recommends signing webhook payloads and sending the signature in headers such as X-Webhook-Signature; private fields are excluded from payloads, and Strapi's docs tell you to implement retries for failed webhook requests, so the receiver needs its own queue.
Audit Trails: Making Every Change Traceable
Built-in Audit Logs cover the Admin Panel; every Content API mutation that bypasses it needs a custom middleware writing to its own append-only store.
What an Audit Trail Should Capture
Audit trails in a CMS have to make five things reconstructable: who acted, what they did, when (in UTC), the state before, and the state after. NIST SP 800-53 AU-3 requires records to establish event type, time, location, source, outcome, and the identity of associated subjects, with "the system security and privacy posture after the event occurred" folded into outcome. The Open Worldwide Application Security Project (OWASP) Logging Cheat Sheet condenses that to "when, where, who and what" and insists on logging "all apparent tampering events, including unexpected changes to state data."
Knowing who published an entry is the easy part. The hard part is the surrounding trail: the draft edits between the last approval and the publish, the role change that gave someone publish rights last Tuesday, and the mutation that arrived through a full-access API token rather than the Admin Panel.
Building Audit Logging in a Headless CMS
Strapi's built-in Audit Logs (Enterprise plan, Strapi 4.6.0 or later) record Content-Type create/update/delete, entry create/update/delete/publish/unpublish, media changes, login success and failure, role and permission changes, and user changes. Each record stores user (name and ID), action, timestamp, and payload. Retention defaults to 90 days and is set in code, not the UI, through auditLogs.retentionDays in /config/admin.js. Set it to your compliance window; purged entries don't come back. As of v5.53.0, users holding both Read and Export permissions can export filtered logs to comma-separated values (CSV).
GitHub issue #23493 confirms that REST and GraphQL Content API mutations are intentionally excluded, so a publish triggered by a CI script with a full-access token leaves no built-in trace, and there's no public API for writing custom events into the built-in store. If you need that coverage, the pattern is a second Document Service middleware that writes to a custom Collection Type you treat as append-only: timestamp, actor_id, action, resource_type, resource_id, before_state, after_state, and checksum.
This middleware registers inside the same register() function as the publish gate. The documented middleware context exposes action, params, uid, and contentType, so actor identity has to come from the request context. That accessor isn't covered in the middleware docs, so confirm it resolves in your version before relying on it:
// src/index.js, inside the same register({ strapi }) shown above
const TRACKED = ['create', 'update', 'publish', 'unpublish'];
strapi.documents.use(async (context, next) => {
if (context.contentType.uid === 'api::audit-log.audit-log') return next();
if (!TRACKED.includes(context.action)) return next();
const { documentId, locale } = context.params;
const before = documentId
? await strapi.documents(context.contentType.uid).findOne({ documentId, locale, status: 'draft' })
: null;
const after = await next();
await strapi.documents('api::audit-log.audit-log').create({
data: {
action: context.action,
actor_id: strapi.requestContext.get()?.state?.user?.id ?? 'system', // verify this accessor in your version
timestamp: new Date().toISOString(),
resource_type: context.contentType.uid,
resource_id: after?.documentId ?? documentId,
before_state: JSON.stringify(before),
after_state: JSON.stringify(after),
},
});
return after;
});The first line stops the middleware from logging its own writes; without it the create call recurses.
A PostgreSQL audit trigger writing into an append-only table in the same database keeps the audit write atomic with the business write. The cost is bloat in the primary database. Whichever store you pick, you will want indexes on resource_id and timestamp, since every audit question comes down to which record changed and when. Strapi documents no built-in forwarding, so getting events into a Security Information and Event Management (SIEM) system, a compliance dashboard, or an observability stack means emitting them yourself through an OpenTelemetry Collector.
Governance Across Locales and Channels
Strapi's docs don't state whether Review Workflow stages apply per locale independently, so treat that as unverified and enforce completeness in middleware. In Strapi 5, i18n is part of core, and a document holds independent draft and published versions per locale. In the Admin Panel, "Clicking on the Publish button will only publish the content for the locale you are currently working on," and bulk actions follow the same rule. Through the Document Service API, publish({ documentId }) publishes only the default locale. Pass a locale string for one locale or '*' for all. On the query side, findMany({ locale: 'fr' }) returns only documents with a fr version, and an unpublished fr variant won't reach a public request that doesn't ask for drafts.
Leaving partial translations as drafts is therefore the primary guard. In practice it's the cheapest one you can ship. The publish middleware extends it to completeness, since context.params.locale is available on publish and you can require, say, that the en version is already published before a translation goes live.
RBAC scopes per locale. The role editor lets you define permissions for each available locale, and the Model Context Protocol (MCP) server docs describe the runtime effect: "a token might allow reading content in en and fr but only creating content in en."
Custom API tokens grant per-endpoint, per-Content-Type permissions. A syndication partner can get a Custom token scoped to find and findOne on article with a 30-day expiry. Per-channel approval rules then reduce to separate Content-Types, or to a channels field the publish middleware checks before letting a mobile-only entry through. A partner feed might require the legal stage before publish while the web channel doesn't, and the middleware reads that field to decide which rule applies.
Automating Governance With Lifecycle Hooks and Webhooks
Governance-as-code means the policy lives in the repository and runs wherever the Document Service is invoked. In Strapi 5 that mostly means Document Service middlewares, which unlike Strapi 4 lifecycle hooks can modify both incoming parameters and the returned result. Lifecycle hooks still fit the users-permissions plugin and the upload package, and strapi.db.lifecycles.subscribe() still works, but they fire multiple times per document action under Draft and Publish and never fire for direct knex queries.
Patterns that pay off quickly:
- Reject publishes missing SEO metadata in the
publishmiddleware shown above, beforenext()runs. - Notify on content expiry from a scheduled task querying a custom
expiresAtfield you add. - Archive stale drafts with the same task, querying
status=draftwith a scoped token.
Together, these patterns move routine governance checks out of manual review and into repeatable code paths.
Webhook pipelines connect publish events to the outside. Strapi fires HTTP POST requests and expects a 200. A Next.js Route Handler calling revalidateTag('products') on entry.publish and entry.unpublish immediately invalidates Incremental Static Regeneration (ISR) caches, and the same receiver can purge the content delivery network (CDN). An n8n workflow can route the same event to Slack.
Leave webhooks.populateRelations in config/server.ts at its default false unless the receiver needs relations. Verify X-Webhook-Signature on receipt, and allowlist webhook URLs if you self-host, because of the Server-Side Request Forgery (SSRF) advisory GHSA-v8wj-f5c7-pvxf against the webhook function.
Governance rules need tests. The official testing guide gives you Jest, Supertest, and in-memory SQLite via DATABASE_FILENAME=':memory:'. The guide includes REST endpoint testing alongside Jest setup and unit-test mocking, so governance tests load a real instance with createStrapi().load() and assert that a publish missing seo.metaDescription, or one from the wrong review stage, rejects.
Putting It All Together: A Content Governance Checklist for Developers
Treat this as a content governance framework template.
Role Matrix
Start with a role matrix that keeps publishing authority narrow and makes ownership explicit.
- One role per job (Author, Editor, Publisher, Admin), publish granted to as few roles as possible.
- Field-level restrictions on price, legal, and SEO fields.
- Custom conditions registered in
bootstrap(). - Role definitions versioned in the repo.
This gives each role only the access its job requires.
Workflow Stages
Define the content states and the checks required before an entry can move between them.
- Draft and Publish on every Content-Type that reaches a public channel.
- Multi-stage review (Enterprise), "from" and "to" roles set per stage.
- A
publishmiddleware enforcing stage and completeness.
These controls keep approval rules consistent across the Admin Panel and API-driven publishing.
Audit Log Scope
Choose an audit scope that covers both administrator actions and Content API mutations.
- Built-in Audit Logs (Enterprise),
retentionDaysset to your compliance window. - Custom middleware logging for Content API mutations.
- A forwarding path to your SIEM or observability stack, plus a checksum or S3 Object Lock.
This combination makes changes traceable even when they originate outside the Admin Panel.
Locale Rules
Make locale behavior explicit rather than relying on default publishing behavior.
- Per-locale permissions in the role editor.
publish()can be called with an explicitlocale; if omitted, it publishes only the default locale.- Translation completeness checked in middleware.
These rules reduce the chance of publishing incomplete translations.
Automation Hooks
Use automation paths that cover Document Service actions and external delivery failures.
- Middlewares over lifecycle hooks for Document Service actions.
- Webhooks with signature verification, receiver-side IP allowlisting, and retry handling.
- An integration test for every rejection path.
This keeps governance behavior testable across internal and external workflows.
API Access Policies
Finish with API policies that limit public and partner access to the endpoints they need.
- Public role limited to
findandfindOne. - Read-only tokens for public consumers, Custom tokens scoped per Content-Type for partners.
statuspassed explicitly on every REST and Document Service call.- Strapi pinned at 5.53.0 or later.
These policies reduce accidental draft exposure and limit the impact of a leaked token.
Start with the permission audit: list every role that can publish and every token with full access, then cut both lists and add the publish middleware with a failing test for it. Review Workflows and Audit Logs need Enterprise. The middleware and token controls above work on Community today, and the Document Service middleware docs are the reference to keep open while you build.





