Who this is for. Anyone making content members-only, and anyone working out why "it works for me" while members get a 404. Afterwards you can tell which gate refused a request, and decide where a check belongs.
Two gates
Every record a visitor asks for is checked twice, inside the database query that loads it:
- Published. The record has a published date and that date is not in the future, so a record scheduled for next week fails until then. Custom entity records are checked only when their type is set to be publishable.
- Permitted. The visitor may read it. The read permission comes from the record's own permissions if it has them, otherwise from its parent record's (a page's parent page, a blog post's blog), and otherwise from the defaults for its entity type and module.
The two are independent. A published record can be closed to a visitor, and a record they may read can be unpublished. It has to pass both, and who has to pass which depends on the kind of user:
| Visitor | Published gate | Permission gate |
|---|---|---|
| Signed out | Applies | Applies, with the permissions granted to everyone |
| A member (user type Frontend user) | Applies | Applies |
| A Backend user | Skipped by default | Applies |
| An Administrator, Super admin or Developer | Skipped by default | Skipped |
"Skipped by default" is the admin setting Show unpublished, which is on unless a user turns it off for themselves. While it is on, a backend user of any type sees unpublished records on the site and through the API. (Members and backend users explains the user types.)
Two symptoms follow. "It works for me but members get a 404" is one of the gates, and you do not notice because, signed in as an administrator, you skip both. And with the default settings a record that is restricted and a record that does not exist answer the same 404, so the response alone cannot tell you which gate refused.
What a refused visitor sees
A record that fails either gate is treated as not found: a 404, rendered with your site's errors/not-found.html. Two security settings change the answer when the record exists but the visitor may not read it:
| Setting | Options | Default |
|---|---|---|
| Access Denied (a signed-out visitor) | Show Page Not Found error (404); Show Unauthorized error (401); Redirect to login page | 404 |
| Access Denied (Authenticated) (a signed-in visitor) | Show Page Not Found error (404); Show Forbidden error (403) | 404 |
On a member site, Redirect to login page is usually what the client wants for signed-out visitors: a member following a link from an email lands on the sign-in page with the message "You need to login to view this", instead of at a dead end. The redirect carries the record's path as /login?redirectUrl=…, and the member comes back to the record after signing in only if your sign-in template copies that parameter into a hidden redirectUrl field; the framework's own sign-in template does not (Where the member lands). A request for JSON gets a 401 with the same message and the sign-in URL instead of the redirect.
The 401 and 403 answers are the framework's own plain pages, not your site's templates, so prefer the 404 or the redirect where the look matters.
Lists never refuse. A page's children, a blog's posts, a $_query result or an API index leave out the records the visitor may not read, with no error and no empty slot where they would have been.