Strapi Role-Based Access Control Configuration

This guide, part of Strapi Self-Hosted Setup, configures who and what can read and write content in Strapi. Access misconfigurations in Strapi rarely warn you: they show up as silent 403 responses during static generation, pages missing relations, or fields exposed in a public API that nobody meant to publish. The guide explains the three separate permission systems Strapi has, isolates the three most common failures (a Public role that grants too much, a build token that grants too little, and permissions that drift between environments), and adds a CI check so regressions never reach production.

The Problem

A retailer’s product listing pages rendered without prices in production, although they worked locally. The build used an API token created by hand in production months earlier, when prices were still a plain field; prices had since moved into a relation to a new price-list type, and the production token’s custom permissions had never been extended to it. Strapi returned the products without the relation, the build succeeded, and the pages went out incomplete. In the same audit, the team found that the Public role could find the customer-note collection, left over from a prototype, which exposed internal notes to anyone who guessed the endpoint.

How Strapi Permissions Work

Strapi has three separate permission systems, and most confusion comes from mixing them up.

Users & Permissions roles for the Content API. The Public role applies to requests without credentials; the Authenticated role, and any custom roles, apply to end users who log in and send a user JWT. Permissions are granted per content type and action (find, findOne, create, update, delete) and per custom route. The JWT identifies the user; Strapi loads the user’s role from the database and checks its permissions for the route.

API tokens. Tokens are not roles. Each token has a type: read-only, full access, or custom with a chosen set of actions per content type. Tokens are meant for servers, such as a frontend build or a server-rendered app, and they do not expire unless you set a duration.

Admin RBAC. Admin panel roles, such as Editor and Author, control what editors can do in the admin panel, including field-level and condition-based restrictions. They do not affect the Content API at all.

For the Content API, there are no field-level toggles: a caller that can find a type receives all of its non-private fields. Hide fields from the public API by marking them private in the schema, by splitting sensitive data into a separate type with its own permissions, or by sanitizing responses in a custom controller.

How Strapi decides a Content API requestA request arrives; if it carries an API token, the token's type and custom actions decide; if it carries a user JWT, the user's role decides; otherwise the Public role decides; denied requests get 403, allowed ones are resolved and private fields are removed before the response.RequestCredential?API tokentype + actionsUser JWTrole from DBPublic roleAction allowed?Resolve + stripprivate fieldstokenjwtnoneyes
Three kinds of callers, three sources of permissions, one sanitization step at the end.

Scenario 1: The Public Role Grants Too Much

Symptom: Content that should be internal is reachable without credentials, such as notes, internal flags or a whole collection left over from a prototype.

Root cause: find and findOne were enabled on the Public role for convenience, or a new content type inherited a permissive setup script. Every non-private field of every enabled type is then public.

Resolution: Treat Public as a strict boundary for content that is genuinely public, such as marketing pages, catalog data and SEO settings. Grant it only the read actions it needs, and mark internal fields private: true in the schema so they never leave the API. For everything else, including builds and server-rendered pages, use API tokens and leave Public closed.

Scenario 2: The Build Token Grants Too Little

Symptom: Builds succeed, but pages show missing relations, empty components or null fields.

Root cause: The build uses a custom API token whose actions do not include find on every type reached through populate. Strapi only returns relations the caller may read, so unreadable relations disappear silently instead of failing the request. Missing populate parameters produce the same symptom, since relations and components are not populated by default.

Resolution: Create one read-only token per environment for builds and server-side fetching, and populate explicitly. A read-only token can find and findOne every type, which is simpler and safer than a custom token that must be extended with each new relation. Use custom tokens only where the scope must be narrower than all published content.

Scenario 3: Permissions Drift Between Environments

Symptom: Requests that work locally return 401 or 403 on staging, or production exposes a type that staging keeps closed.

Root cause: Role permissions and API tokens live in the database, not in the code. They were clicked together separately in each environment and have drifted apart. Tokens are also environment-specific by design, because they are hashed with each environment’s salt.

Resolution: Keep role permissions in code. Either use a configuration sync plugin that exports roles and permissions to files you commit and import on deployment, or set permissions in a bootstrap function that runs at startup, as below. Create tokens per environment and store them in each environment’s secret store.

Which caller gets which credentialFor anonymous visitors, logged-in end users, frontend builds, server-rendered pages, preview routes and editors, the credential and permission source recommended in Strapi.CallerCredentialPermission sourceAnonymous visitornonePublic role, public types onlyLogged-in end useruser JWTAuthenticated or custom roleFrontend buildread-only API tokentoken typeServer-rendered pagesread-only API token, server onlytoken typePreview routeseparate token, server onlytoken type, drafts allowedEditoradmin sessionadmin RBAC, field conditions
Content API callers use the Public role, user roles or tokens; editors use admin RBAC.

Implementation

A bootstrap function makes Public permissions explicit and repeatable, so every environment starts with the same grants.

JavaScript
// src/index.js
const PUBLIC_READ = ["api::page.page", "api::article.article", "api::category.category", "api::global.global"];

module.exports = {
  async bootstrap({ strapi }) {
    const publicRole = await strapi.db.query("plugin::users-permissions.role").findOne({ where: { type: "public" } });
    const wanted = PUBLIC_READ.flatMap((uid) => [`${uid}.find`, `${uid}.findOne`]);

    const existing = await strapi.db.query("plugin::users-permissions.permission").findMany({
      where: { role: publicRole.id },
    });

    // Remove anything not in the list, so grants made by hand do not survive a deployment.
    for (const p of existing.filter((p) => !wanted.includes(p.action))) {
      await strapi.db.query("plugin::users-permissions.permission").delete({ where: { id: p.id } });
    }
    for (const action of wanted.filter((a) => !existing.some((p) => p.action === a))) {
      await strapi.db.query("plugin::users-permissions.permission").create({ data: { action, role: publicRole.id } });
    }
  },
};

Internal fields are marked private in the schema, so no Content API caller receives them:

JSON
// src/api/product/content-types/product/schema.json (excerpt)
{
  "attributes": {
    "name": { "type": "string", "required": true },
    "internalNotes": { "type": "text", "private": true },
    "supplierCost": { "type": "decimal", "private": true }
  }
}

The frontend reads with a read-only token and explicit populate, on the server only:

TypeScript
// lib/strapi.ts
import qs from "qs";

export async function strapiGet<T>(path: string, query: object): Promise<T> {
  const url = `${process.env.STRAPI_URL}/api/${path}?${qs.stringify(query, { encodeValuesOnly: true })}`;
  const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.STRAPI_READ_TOKEN}` } });
  if (!res.ok) throw new Error(`Strapi ${res.status} for ${path}`);
  return res.json() as Promise<T>;
}

// Explicit populate: relations the page needs, nothing more.
export const productQuery = { populate: { images: true, prices: { populate: ["priceList"] } } };

Validating Permissions in CI

Manual checks in the admin panel do not scale. Add two automated checks to CI against a staging instance seeded with fixtures.

  1. Anonymous probe. For every content type, request /api/<plural> without credentials. Types on the public list must return 200; all others must return 403. A new type that is accidentally public fails the build.
  2. Build contract test. Fetch each page type with the build token and the same query the frontend uses, and assert that required relations and components are present and non-empty. A token that cannot read a relation fails here instead of in production.
  3. Private field check. Assert that fields marked private, such as internalNotes, never appear in any response.

These tests take seconds, and they catch exactly the failures that Strapi reports silently.

Configuration Reference

Item Recommendation Why
Public role read actions on public types only Nothing internal is reachable anonymously.
Internal fields private: true in the schema Content API has no field-level permissions.
Build token read-only, per environment Relations never vanish silently.
Preview token separate, server-side only Drafts stay off public paths.
Permissions bootstrap code or config sync No drift between environments.
Token duration set, with rotation in CI Leaked tokens expire.
CI checks anonymous probe, contract test Silent failures become red builds.

Gotchas & Edge Cases

  • Admin roles do not protect the API. Restricting a field for the Editor role in the admin panel does not hide it from the Content API. Use private for that.
  • New content types. A generator or a copied setup script may grant Public access to every new type. The anonymous probe catches this.
  • Custom routes. Routes added by plugins or custom controllers get their own permissions; they are closed by default, but check them after installing plugins.
  • populate=* depth. It populates one level only. Nested relations need explicit populate paths, and each level must be readable by the caller.
  • Full-access tokens in the browser. Any token shipped to the client is public. Keep tokens on the server, and use the Public role for truly client-side reads.

Worked Example

The retailer replaced its hand-made production token with a read-only token per environment, moved Public permissions into a bootstrap function, marked supplier costs and internal notes private and deleted the prototype’s Public grants. The anonymous probe and build contract test run on every pull request against staging. In the first month, the contract test caught two new relations that the old custom token would not have covered, and the anonymous probe blocked a new draft-campaign type that had been created with public read access.

Access problems reaching production per quarterAccess control problems found in production per quarter, before and after moving permissions into code and adding CI checks.Before: missing relations3 incidents per quarterBefore: exposed types2 incidents per quarterAfter: missing relations0 incidents per quarterAfter: exposed types0 incidents per quarter
Once permissions lived in code and CI probed them, problems stopped reaching production.

Designing Custom Roles for End Users

Sites with logged-in users, such as members’ areas, often need more than the built-in Authenticated role. Create custom roles for distinct groups, such as member and partner, and assign them at registration or through an admin process. Remember that roles grant actions on whole types, not on individual entries: a member who can find the order type can find every order unless a policy or custom controller filters by the current user. Write a small policy that adds the user filter for owned content, and test it with two users who must not see each other’s data. Keep content that differs by audience in separate types where possible, since type-level permissions are easy to audit and entry-level filtering is easy to get wrong.

Rollout Checklist

  • Limit the Public role to read actions on genuinely public types.
  • Mark internal fields private in the schema.
  • Use a read-only API token per environment for builds and server rendering.
  • Keep role permissions in code with a bootstrap function or config sync.
  • Keep every token on the server and set a duration.
  • Run the anonymous probe and build contract test in CI.

Frequently Asked Questions

Can I hide a single field from the Public role?

Not with role settings. Mark the field private, move it to a separate type, or sanitize the response in a custom controller.

Should the build use a user JWT?

No. User JWTs are for end users; builds should use an API token, which is scoped, revocable and independent of any person’s account.

Why did my request return data without the relation instead of an error?

Strapi omits relations the caller cannot read. Check the token’s actions for the related type and the populate parameter.

Do tokens transfer between environments?

No. Tokens are hashed with each environment’s salt, so create them separately in each environment and store them as secrets.