Guide 07 · Developer docs

Members, accounts and gated content

Building a member area on Core dna: who a member is in the data, the sign-in flows and their templates, single sign-on and passkeys, the my-account routing convention, members-only content, sessions and the page cache, and the security checks to run before launch.

L2
Unit 05 · Members-only content

Two gates: published and permitted

A visitor sees a record only if it is published and they may read it. The two checks are independent, a refusal looks like a missing page, and administrators skip both. Where each check runs, what a refused visitor sees, and why a check in a template is not a gate.

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:

VisitorPublished gatePermission gate
Signed outAppliesApplies, with the permissions granted to everyone
A member (user type Frontend user)AppliesApplies
A Backend userSkipped by defaultApplies
An Administrator, Super admin or DeveloperSkipped by defaultSkipped

"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:

SettingOptionsDefault
Access Denied (a signed-out visitor)Show Page Not Found error (404); Show Unauthorized error (401); Redirect to login page404
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.

Template gating and route gating

There are two places to stop a visitor, and only one of them is a gate:

  • At the route: the record's permissions, or a page under /my-account, which requires sign-in before any template runs. The platform refuses the request itself, whichever way the record is being read.
  • In the template: an if around the content, or a redirect. It hides the content on that page, and only there.
Template code A template check: hides this page's content, protects nothing else
{$_response->preventCaching()}
{if $_currentUser->isLoggedIn()}
  {$page->membersBody}
{else}
  <p><a href="{$_url->getPath('login')}">Sign in</a> to read the rest.</p>
{/if}
{% do _response.preventCaching() %}
{% if _currentUser.isLoggedIn() %}
  {{ page.membersBody }}
{% else %}
  <p><a href="{{ _url.getPath('login') }}">Sign in</a> to read the rest.</p>
{% endif %}

That check is useful for choosing between two versions of a page, and it is never enough on its own to keep a record private. The same record can be reached without running your template:

  • as an entry in a listing, a search result or the sitemap;
  • through $_query in any other template;
  • as JSON, by requesting the page's own URL with Accept: application/json, which answers with the record's name, slug, dates and custom field values without rendering the template;
  • through the API, by any integration whose backend user may read it.

So restrict the record itself (Restricting a record to a group) and use the template check for presentation. A page whose output depends on who is looking must also prevent caching, or its signed-out version is stored and served to members too (Member pages and the page cache). Listings, search and the API covers checking every other way in.

What each kind of route checks

Before relying on a route to keep someone out, know which check it makes. A site cannot change these:

RouteRequiresOtherwise
Content pages: pages, books, blog posts, FAQ, products, custom entity recordsBoth gates on the record404, or what the settings above say
Everything under /my-accountSomeone signed inA redirect to /login
/registerNobody signed inA redirect to the account page, "You are not allowed to view this page while logged in"
The REST and GraphQL APIsA backend user's credential401 signed out; a member is refused like a missing route (A site member and a backend user)
A synchronous job endpointWhat the job's settings saySee Custom endpoints

Only the /my-account routes guarantee a signed-in visitor. On every other page a template that reaches into the visitor's records must allow for nobody being signed in: $_currentUser->getPerson() is null for a signed-out visitor, and calling a method on it stops the page ("Call to a member function on null"). Test isLoggedIn() first.

Go deeper

Was this page helpful?

Your answer goes to the docs team.