You created a collection type, added entries, published them, and called the API:
curl https://cms.example.com/api/articles{
"data": null,
"error": {
"status": 403,
"name": "ForbiddenError",
"message": "Forbidden"
}
}403 means the request was understood and refused. The route exists, Strapi identified who is asking, and that identity does not have permission to perform this action. This is different from a 401 (no valid credentials at all) and from a 404 (no such route).
Work through these in order.
1. The Public role has no permission on that content type
This resolves the large majority of cases.
Every unauthenticated request to Strapi is executed as the Public role, and that role starts with no permissions on anything you create. A new collection type is invisible to the outside world until you grant access.
Settings → Users & Permissions plugin → Roles → Public
Expand your content type and tick the actions you want to expose:
find: the list endpoint,GET /api/articlesfindOne: the single-entry endpoint,GET /api/articles/:documentIdcreate,update,delete: write actions. Do not enable these for Public.
Click Save. The change applies immediately; you do not need to restart.
Two things to know:
findandfindOneare separate permissions. Granting onlyfindgives you a working list endpoint and a 403 on every individual entry, which produces a very confusing bug report.- Custom controller actions need their own tick. If you added a custom route, its action appears in this list and defaults to off.
2. You are using an API token that lacks the permission
If you are sending Authorization: Bearer <token>, the Public role no longer applies; the token's own permissions do.
Settings → API Tokens
Token types:
| Type | Access |
|---|---|
| Read-only | find and findOne on everything |
| Full access | Everything, including create, update, delete |
| Custom | Exactly the actions you tick |
A Custom token silently omits any content type you created after generating it. If your 403 started right after adding a collection type, this is very likely why, so edit the token and grant the new permissions.
Also check the obvious:
- The header is
Authorization: Bearer <token>, with the space and the correct case. - The token has not expired. Tokens can be created with a lifespan.
- You are using the token for the right environment. Tokens are stored in the database, so a staging token does not work against production.
- The token was copied in full. It is shown exactly once at creation; a truncated paste gives a 401, not a 403, but people frequently conflate the two.
3. You are using a Users & Permissions JWT
If your frontend logs users in via /api/auth/local, requests carry a user JWT and the Authenticated role's permissions apply, a different role from Public, configured on the same screen.
Settings → Users & Permissions plugin → Roles → Authenticated
A common surprise: Public has an endpoint enabled but Authenticated does not, so the API works for logged-out visitors and 403s for logged-in ones.
Do not confuse the two token systems. An API token (Settings → API Tokens) is a server-to-server credential. A user JWT comes from the Users & Permissions login flow. They resolve to different permission sets, and a JWT sent where an API token is expected produces a 403.
4. You are hitting the admin API instead of the content API
Routes under /admin and the Content-Type Builder API require an admin session, not an API token. GET /content-manager/collection-types/api::article.article is an admin route; the public equivalent is GET /api/articles.
If you are scripting against Strapi, use /api/....
5. It is not a 403: you are getting an empty array
A different failure that gets reported as "403-ish": a 200 with "data": [] even though entries exist.
That is Draft & Publish. The REST API returns published documents by default. Entries saved but never published are invisible.
Either publish the entries, or request drafts explicitly:
GET /api/articles?status=draftReading drafts requires appropriate permissions, and the Public role should generally not have them.
Coming from Strapi v4: the v4
publicationState=previewparameter was replaced in v5 bystatus=draft. v4'spublicationState=liveis now the default behaviour. There is no fallback:status=publishedreturns nothing for a document that has never been published, rather than silently falling back to its draft. If you usedpublicationStatein back-end code via the Entity Service, a codemod handles the change; front-end REST and GraphQL calls have to be updated by hand.
status also applies to writes, and the default catches people out: a POST or PUT publishes immediately unless you pass status=draft. That is the opposite of the Document Service API, which defaults to draft in back-end code.
To query documents by how their draft and published versions relate (never published, modified since last publish, and so on), pair status with publicationFilter:
GET /api/articles?status=draft&publicationFilter=never-published6. A route-level policy or middleware is rejecting the request
If you added a policy to a route, it runs before the controller and can return 403 on its own:
export default {
routes: [
{
method: 'GET',
path: '/articles',
handler: 'article.find',
config: {
policies: ['global::is-owner'],
},
},
],
};Check src/policies, src/api/*/policies, and the config.policies array on your routes. A policy that throws ForbiddenError produces a response identical to a missing permission.
7. CORS is making it look like a 403
If the failure only happens in the browser and curl works, you are looking at CORS, not permissions. The browser blocks the response and the console message can mention a status you never actually received.
export default [
'strapi::logger',
'strapi::errors',
'strapi::security',
{
name: 'strapi::cors',
config: {
origin: ['https://www.example.com', 'http://localhost:3000'],
},
},
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];Reproduce with curl first. If curl succeeds and the browser does not, it is CORS.
A diagnostic sequence
# 1. Does the route exist at all? 404 = wrong path or the content type has no routes
curl -i https://cms.example.com/api/articles
# 2. Does it work with a full-access token? Yes = a permissions problem, not a code problem
curl -i https://cms.example.com/api/articles \
-H "Authorization: Bearer $FULL_ACCESS_TOKEN"
# 3. Are there published entries?
curl -s "https://cms.example.com/api/articles?status=draft" \
-H "Authorization: Bearer $FULL_ACCESS_TOKEN" | jq '.meta.pagination.total'
# 4. What routes does Strapi think it has?
npx strapi routes:list | grep articlestrapi routes:list is underused and settles a lot of arguments: it prints every registered route with its method, path, and handler.
Quick reference
| Situation | Where to look |
|---|---|
| No auth header | Users & Permissions → Roles → Public |
Authorization: Bearer <API token> | Settings → API Tokens |
| Logged-in user JWT | Users & Permissions → Roles → Authenticated |
find works, findOne 403s | Tick findOne separately |
| Custom route 403s | Tick the custom action; check route policies |
200 with empty data | Draft & Publish; publish, or use status=draft |
| Works in curl, fails in browser | CORS |
| Started after adding a content type | Re-grant permissions on Public / Custom API tokens |
One thing not to do
Do not grant find and findOne on every content type to Public as a shortcut. The Public role is the internet. Enable exactly what your frontend needs, and use fields and populate deliberately so you are not exposing internal fields alongside the ones you meant to publish.
Further reading




