A fresh Strapi install is closed by default, and that is the good news: the Public role starts with nothing enabled, so a new content type answers 403 Forbidden to anonymous requests until you decide otherwise. The risk is not the defaults. It is what people do to make the front end work on a Friday afternoon - tick every box on Public, create a Full access API token and paste it into a browser bundle, leave sign-ups open, and give every editor the Super Admin role. This guide goes through the four separate permission systems Strapi has, what each one controls, and the order in which to lock a new install down, with a curl test at every step so you know the change did what you think it did.
It covers Strapi 5, the current major version. Menu names move slightly between 5.x releases, so where a label matters it is given as it appears in Settings, and the general shape holds on Strapi 4 as well.
Four different kinds of "user" in Strapi#
Strapi has four identities that all get called "users", and most mistakes come from mixing them up. Each lives in a different table, is managed in a different screen and reaches a different part of the application.
| Identity | Where it is managed | What it can reach |
|---|---|---|
| Admin user | Settings, Administration Panel, Users | The admin panel at /admin |
| End user | Content Manager, User (Users and Permissions) | The content API, through a JWT |
| API token | Settings, Global settings, API Tokens | The content API, through a bearer token |
| Anonymous visitor | Nothing to manage | The content API, as the Public role |
Admin users are your editors and developers. They sign in to the admin panel with an email and password, and what they may do there is set by an admin role. They have no access to the content API through that login; the admin panel talks to its own internal routes under /admin.
End users belong to the Users and Permissions plugin: the people who register on your site, if you let them. They are ordinary content entries of the User type, they authenticate against /api/auth/local, and they receive a JWT. Their permissions come from a Users and Permissions role, either Authenticated or one you create.
API tokens are for software, not people: a static site generator at build time, a mobile app's backend, a script. A token carries its own permission list and is sent in an Authorization header.
The anonymous visitor has no credentials at all. Whatever you allow them is allowed to the entire internet, including search engine crawlers and scrapers. Strapi treats such a request as belonging to the Public role.
Keeping this picture in your head answers most "why can't I..." questions. An API token cannot open the admin panel. An admin user's password does not work on /api. A Public permission is a permission for everybody, including the people holding the other three kinds of credential, because they can simply omit their credentials and be treated as the Public role.
The Public role: start at zero#
Open Settings, then under Users and Permissions plugin choose Roles. There are two built-in roles: Public and Authenticated. For every content type, the role editor lists the actions available: find (the list), findOne (one entry), create, update and delete. Single types have their own set of actions. A new content type starts with none ticked for Public.
The rule is simple to state and surprisingly rarely followed: enable the minimum number of actions, on the minimum number of content types, and never anything that writes.
- For a blog or marketing site,
findandfindOneon the content types the public pages actually render is usually the whole list. - Do not enable
create,updateordeletefor Public. If a contact form needs to create entries, put a small server-side route or serverless function in front of it, or accept that one specific content type is writable by anyone and treat what lands in it as hostile input. - Look at what else is in the list. The Users and Permissions section has
user.find,user.findOne,user.countand friends. Tickinguser.findfor Public publishes a list of your registered users, with their email addresses, to the internet. - The Upload section lists
upload.find,upload.findOneandupload.destroyamong others. Files referenced from your content are served as ordinary static files from their URL; they do not need these permissions, and a publicdestroywould be a disaster.
Public also carries a set of auth actions (login, registration, password reset) enabled by default, because the sign-in routes would not work otherwise. Those are the reason the next section matters.
Test what you have. From a machine with no token, with cms.example.com replaced by your own host:
$ curl -i https://cms.example.com/api/articles$ curl -i https://cms.example.com/api/pages$ curl -i https://cms.example.com/api/usersA content type you have not opened answers with HTTP/1.1 403 Forbidden and a JSON body whose error.name is ForbiddenError. A type you have opened answers 200 with a data array. A 404 instead of a 403 means the route does not exist: the content type's plural name is different from what you typed. The plural name is the one shown in the Content-Type Builder as the API ID (plural), and it is the path segment after /api/.
What Public sees beyond the entry itself
Two query parameters decide how much of the graph comes back with an entry. By default Strapi 5 returns only the entry's own fields; relations, media and components appear when the request asks for them with populate. A request using ?populate=* asks for one level of everything. The role being used must also be allowed to read the related content type, so opening article does not open author by implication, but it is still easy to expose more than you meant by opening both.
Pagination is the second. Left alone, a client can request a page size of its choice. Cap it in config/api.js:
module.exports = { rest: { defaultLimit: 25, maxLimit: 100, withCount: true, },};defaultLimit applies when the request does not say, and maxLimit is the ceiling a request cannot exceed. A small maxLimit will not stop a patient scraper, but it removes the one-request dump of the entire table. If you run the GraphQL plugin, it has equivalent settings (defaultLimit, maxLimit, depthLimit, amountLimit) in config/plugins.js, and the Apollo sandbox is controlled by playgroundAlways; leave that off in production.
The Authenticated role and sign-ups#
The Authenticated role is what a logged-in end user gets. It starts nearly empty too. The question for most sites is not what Authenticated may do, but whether anyone should be able to become Authenticated at all.
If your site has no member area, the answer is no, and it takes one switch. Under Users and Permissions plugin, open Advanced settings and turn off Enable sign-ups. With that off, POST /api/auth/local/register stops creating accounts. Check the other settings on the same page while you are there:
| Setting | What to do |
|---|---|
| Default role for authenticated users | Keep Authenticated, never a role with write access |
| Enable sign-ups | Off unless people genuinely register |
| One account per email address | On |
| Enable email confirmation | On if sign-ups stay open and email works |
| Reset password page | Set to your front end's reset URL, not the default |
Email confirmation needs a working email provider, so do not turn it on before sending a test message, or new users get locked out of their own accounts. Strapi's default provider, sendmail, relies on a mail transfer agent being present on the machine, which a small container usually does not have.
If you keep registration open, the registration route is a public write endpoint with an automatic account creation behind it. Treat it like any login form: put a rate limit in front of it. The reverse proxy is the place, covered in the section on hardening the edge below.
The JWT that Users and Permissions issues has a lifetime and a signing secret. The secret is jwtSecret, read from JWT_SECRET in a generated project. The lifetime is jwt.expiresIn in config/plugins.js, and the default is long (30 days at the time of writing). For anything beyond a hobby site, shorten it:
module.exports = ({ env }) => ({ 'users-permissions': { config: { jwtSecret: env('JWT_SECRET'), jwt: { expiresIn: '7d', }, }, },});JWTs are not stored server-side, so there is no list of active sessions to revoke. Shortening expiresIn is the mitigation, and changing JWT_SECRET is the emergency stop that invalidates every one at once, at the price of logging every end user out. The Strapi production guide lists the other secrets and what breaks when each changes.
API tokens: scoped, expiring, per consumer#
An API token is the right credential for software. Under Settings, Global settings, API Tokens, choose Create new API Token. You fill in four things.
- Name and description: use the consumer's name, such as
marketing-site-build. When a token leaks, the name is how you know which system to fix. - Token duration: 7 days, 30 days, 90 days or Unlimited. Pick the shortest that you are willing to renew. Unlimited should be a deliberate choice, not the way out of a dialog.
- Token type: Read-only, Full access or Custom.
The three types are very different in blast radius:
| Type | What it allows | Use for |
|---|---|---|
| Read-only | find and findOne on every content type | Build-time fetching of everything |
| Full access | Every action on every content type | Almost nothing |
| Custom | Only the actions you tick, per content type | The default choice |
Full access is the one to avoid. A token that can delete every entry, in a build script that also runs on a CI server that other people can read, is a very expensive thing to lose. Read-only is safe against writes but still covers every content type, including ones the consumer does not need. Custom lets you tick exactly find and findOne on the two content types the front end renders, and nothing else, and it should be your starting point.
When you save, the token value is displayed. Strapi stores a hash of the token, derived with the salt API_TOKEN_SALT, so in a default setup you can copy the value only at that moment. Newer 5.x releases can show the token again when an encryption key is configured (ENCRYPTION_KEY, read by config/admin.js as secrets.encryptionKey in a generated project); if your .env has one, keep it as unchanging as the other secrets. Either way, put the value straight into the consuming system's secret store.
Use it as a bearer token:
$ curl -i https://cms.example.com/api/articles \ -H "Authorization: Bearer $STRAPI_TOKEN"Test the boundary as well as the success. With a Custom token that has find on articles only:
$ curl -i https://cms.example.com/api/pages -H "Authorization: Bearer $STRAPI_TOKEN"$ curl -i -X DELETE https://cms.example.com/api/articles/abc123 \ -H "Authorization: Bearer $STRAPI_TOKEN"Both must answer 403. A wrong or expired token answers 401 Unauthorized; a token that is valid but not allowed that action answers 403. Telling those two apart saves a great deal of debugging: 401 is about who you are, 403 is about what you may do.
A few habits keep tokens manageable:
- One token per consumer. If the staging site and the production site share a token, you cannot revoke one without breaking the other.
- Never ship a token to a browser. Anything in a client-side bundle is public. A token in
NEXT_PUBLIC_or equivalent is a Public permission with extra steps. If a browser needs data, make that data public through the Public role (read-only, a few content types) and keep the token for server-side code. - Regenerate on suspicion. The token's edit page has a Regenerate button that issues a new value and invalidates the old one. The permissions stay.
- Rotate on a calendar. With 90-day tokens, a calendar entry two weeks before expiry is cheaper than an outage.
- Keep tokens in the host's environment, not in the repository. Environment variables and secrets goes through why.
API tokens only apply to the content API. They cannot sign in to the admin panel and cannot change content types, roles or settings.
Transfer tokens are a different thing
Under the same Global settings you will also find Transfer Tokens. They authorise strapi transfer, the live data copy between two Strapi instances, and they come with a Push or Pull direction. Their salt is TRANSFER_TOKEN_SALT. A transfer token with Pull can read your entire content and media. Create one only for the moment you need it, give it the shortest duration, and delete it afterwards.
Admin users and admin roles#
Admin users are the people who log in at /admin. Under Settings, Administration Panel, Roles, a fresh install has three roles:
| Role | What it can do |
|---|---|
| Super Admin | Everything, including settings, roles, tokens and plugins |
| Editor | Create, read, update, delete and publish all content |
| Author | Create, read, update and delete their own content |
The very first person to open /admin on a fresh install creates the Super Admin. Do that yourself, immediately, before pointing a public domain at the server. After that, the rule is that almost nobody should be a Super Admin. It is the only role that can create API tokens, change the Public role, install plugins and invite other admins, and it cannot be edited or limited.
Invite people from Settings, Administration Panel, Users, with Invite new user. If the server has an email provider, they receive a link. If not, Strapi shows you the registration link to copy and send by hand, which expires with use and should not be pasted into a public channel. A new admin chooses their own password at that link.
What the built-in roles miss is anything finer. Duplicate Editor or Author and edit the copy. Each role is a grid of content types against actions (Create, Read, Update, Delete, Publish), plus the fields within each type, plus Plugins and Settings sections. Two features make it practical:
- Field-level permissions. Under a content type, expand the fields and untick the ones a role must not see or change. A pricing field that only managers edit is a fair example.
- Conditions. A permission can be limited by a condition such as "Is creator", so an Author can edit entries they made and none of anyone else's. Without a condition, the permission applies to every entry.
Settings permissions deserve a deliberate look. A role can be allowed to read or create API tokens, or to read the roles, without being allowed to touch anything else. Give those to nobody except the people who run the site.
Some things to do on every install:
- Create a named Super Admin for yourself, and a second one held back for emergencies, rather than sharing a single login.
- Use each person's real email address. A shared
admin@mailbox makes the activity trail useless and every password reset a negotiation. - When someone leaves, deactivate their account (the Users list has an active toggle) rather than leaving it to expire.
- Use long, unique passwords from a password manager. Strapi's community edition has no built-in two-factor sign-in for the admin panel; single sign-on is part of the paid editions. Check the Strapi documentation for your version if that matters to you.
If you lock yourself out, you do not have to touch the database. From the project directory the CLI can create a new Super Admin or reset a password:
$ npm run strapi admin:create-user -- --firstname=Ada --lastname=Admin \ --email=ada@example.com --password='a-long-unique-password'$ npm run strapi admin:reset-user-password -- --email=ada@example.com --password='another-one'Anyone who can run those commands already has the project folder, so the commands add no new risk. They are also a reason to restrict who can reach the server's files and console in the first place.
Setting permissions from code instead of clicking#
Roles and permissions are data. They live in the database, not in the project, which has a consequence that surprises people: a Git deploy to a second environment does not carry them. Your staging site may be wide open, or production may be shut tight while the code that needs a permission is already live. Verify after every migration, and consider writing the Public permissions down in code so a rebuilt environment starts in a known state.
The pattern the Strapi demo projects use is a bootstrap function in src/index.js that looks up the role and creates the permission entries it needs. Actions are named api::<singular>.<singular>.<action>:
module.exports = { register() {}, async bootstrap({ strapi }) { const publicRole = await strapi.db .query('plugin::users-permissions.role') .findOne({ where: { type: 'public' } }); const actions = ['api::article.article.find', 'api::article.article.findOne']; for (const action of actions) { const existing = await strapi.db .query('plugin::users-permissions.permission') .findOne({ where: { action, role: publicRole.id } }); if (!existing) { await strapi.db .query('plugin::users-permissions.permission') .create({ data: { action, role: publicRole.id } }); } } },};This only adds permissions; it never removes one somebody ticked by hand, so it is a floor, not a lock. Even so, it means a fresh environment serves the public pages without a manual click-through, and the list of what is public sits in version control where a reviewer can see it. Change article to the singular name of your own content type.
Hardening the edge: proxy, CORS and rate limits#
Permissions decide what an authenticated or anonymous request may do. The layers around them decide who gets to ask.
CORS is configured by the strapi::cors middleware in config/middlewares.js. The default allows any origin. CORS is a browser rule and does not stop curl, but a restrictive list stops other websites from calling your API with their visitors' browsers:
module.exports = [ 'strapi::logger', 'strapi::errors', 'strapi::security', { name: 'strapi::cors', config: { origin: ['https://www.example.com'], methods: ['GET', 'HEAD', 'OPTIONS'], headers: ['Content-Type', 'Authorization'], keepHeaderOnError: true, }, }, 'strapi::poweredBy', 'strapi::query', 'strapi::body', 'strapi::session', 'strapi::favicon', 'strapi::public',];Keep the middleware order as the generated file has it and change only the strapi::cors entry. The admin panel is served from the same origin as the API, so it is unaffected by a front-end-only origin list.
Rate limits belong at the reverse proxy for the routes where guessing happens: the admin login at /admin/login and the end-user login and registration under /api/auth/local. Strapi has rate-limit settings of its own for the admin login (an admin.rateLimit block in config/admin.js), and they are worth enabling, but a limit at the proxy works the same way whichever Strapi version you run and protects the process from the load too. In nginx:
limit_req_zone $binary_remote_addr zone=strapi_auth:10m rate=10r/m;server { # ... TLS and the usual proxy_pass block ... location = /admin/login { limit_req zone=strapi_auth burst=5 nodelay; proxy_pass http://127.0.0.1:1337; } location /api/auth/ { limit_req zone=strapi_auth burst=5 nodelay; proxy_pass http://127.0.0.1:1337; }}Excess requests get a 503 by default; add limit_req_status 429; if you prefer the more accurate code. The surrounding proxy setup is in the nginx reverse proxy guide, and firewall rules that matter covers keeping the Node port itself off the public internet if the proxy runs on the same machine.
One structural option is worth knowing: the admin panel and the API do not have to be public together. If the editors work from known networks, you can restrict /admin at the proxy by address while leaving /api open for the front end. That is an extra layer, not a replacement for roles.
Strapi on RE:NODE#
On RE:NODE, Strapi is a one-click app on the app hosting page. It is installed and built on the server's first start, with data in SQLite on the server, so the lock-down starts the moment the first start finishes: open /admin, create the Super Admin, and work through the steps in this guide before you share the address. The proxy slot issues your certificate once an A record points at the address shown, and the real client IP arrives in X-Forwarded-For, which matters for the rate limits above.
There is a second set of permissions worth using, and it is the panel's own. Subusers on a server can be given granular permissions such as console only or files only, and each server has an activity log, so the person who only needs to read logs does not need to be able to edit the .env file where your Strapi secrets live. See the guides for how people and roles work in the panel. Turn on TOTP two-factor sign-in for your own panel account too: someone who controls your panel account controls the file manager, and with it every secret in the project.
Strapi's .env is in the project folder and can be edited in the file manager or over SFTP. Because roles, permissions and API tokens are stored in the database, a backup slot taken from the Schedules tab or on demand is what protects them; restore one occasionally to prove it, since the tokens come back only if the salt in .env is the same as it was.
Verifying the lock-down#
Run the same short checklist after any change to roles, and on every new environment. It takes five minutes and catches the common failures.
- Anonymous, closed type.
curl -ia content type you did not open. Expect403. - Anonymous, open type.
curl -ione you did open. Expect200and only the fields you meant to publish. - Anonymous, write.
curl -i -X POSTto an open collection with a small JSON body of the form{"data":{}}. Expect403. - Anonymous, users.
curl -i https://cms.example.com/api/users. Expect403. - Registration.
curl -i -X POST https://cms.example.com/api/auth/local/registerwith a JSON body. If sign-ups are off, expect an error, not a new account and a JWT. - Token, in scope. Request what the token is for. Expect
200. - Token, out of scope. Request something it should not reach. Expect
403. - Bad token. Change one character. Expect
401. - Admin accounts. Read through Settings, Administration Panel, Users and delete or deactivate anyone you do not recognise. Count the Super Admins.
- API Tokens list. Delete every token with a name nobody can explain, and every Full access token you can replace with a Custom one.
If a check fails, resist fixing it in the UI alone. Note what the right state is, then set it, then run the check again. A permissions page is easy to misread; a status code is not.
Troubleshooting#
`403 Forbidden` on a type you opened. The role was saved, but the request is being matched to a different one. Check whether you sent an Authorization header: a valid end-user JWT uses the Authenticated role, which has its own permission grid and may not have what Public has. Also confirm the action is ticked for find or findOne as appropriate; an entry fetched by ID needs findOne.
`401 Unauthorized` with a token that looked right. A leading or trailing space or newline in the value, a missing Bearer prefix, a token past its duration, or a changed API_TOKEN_SALT. Create a new token to rule out the salt.
`403` for a token after you edited it. You switched the type to Custom and saved with nothing ticked. A Custom token with no actions allows nothing.
Every token stopped working after a migration. The salt differs from the one the tokens were hashed with. Restore API_TOKEN_SALT from the old environment's .env, or issue new tokens and update each consumer.
Permissions vanish on a rebuilt server. They were in the database, and the database was new. Restore a backup that includes it, or use a bootstrap function like the one above.
A new content type is invisible to the front end. It starts closed. Open find and findOne on the correct role, or on the token, and test again.
The Roles page is missing in Settings. Your admin role does not include permission to read roles. Ask a Super Admin.
FAQ#
What is the difference between the Public role and an API token?
The Public role applies to anyone who sends no credentials, so its permissions are open to the whole internet. An API token applies only to requests that carry it, with a permission list of its own. Use Public for data the whole world may read, and a Custom token for anything else software needs.
Is a Read-only API token safe to put in my front end?
Not in a browser bundle. Read-only prevents writes, but it can read every content type, including drafts-in-progress content types and fields you never meant to publish, and anyone can copy it from the page source. Use it only in server-side code or a build step.
Can I make an API token that only reads one content type?
Yes. Create the token with the Custom type and tick find and findOne for that content type only. Test against another type to confirm it answers 403.
How do I stop strangers registering accounts?
In Settings, Users and Permissions plugin, Advanced settings, turn off Enable sign-ups. Then run a POST to /api/auth/local/register and confirm that no account is created. If you do need registration, keep email confirmation on and rate-limit the route.
Why does a Strapi API token stop working after I redeploy?
Almost always because API_TOKEN_SALT changed. Tokens are hashed with that salt, so a different value makes every stored token unrecognisable. Keep the production .env stable across rebuilds.
Do roles and permissions travel with my code?
No. They are stored in the database. A deploy, or a strapi export archive, does not reliably carry them to another environment, so check them on each one, or set the Public permissions in a bootstrap function so a new environment starts with the right list.


