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

Listings, search and the API

A record is read in more places than its own page: listings, other templates, search, the sitemap, its URL requested as JSON, and the API. What each of them shows of a gated record, and the check to run on a members-only section before launch.

Who this is for. Whoever signs off a members-only section. Afterwards you know every way a gated record can be read, and you have a check that proves none of them shows it to the wrong people.

Where a record can be read

A record restricted by its permissions is checked in the query that loads it, so every way in below applies the restriction. A record "gated" only by a check in its own template is protected on that page and nowhere else:

Way inRestricted recordTemplate check only
Its own pageRefused: 404, or the sign-in redirectWhatever the template decides
A listing: a page's children, a blog's posts, a $_query loop in any templateLeft outListed, with whatever the listing prints: title, summary, image, date, price
Its URL requested as JSONRefused, as JSONAnswered without the template (below)
The APIRead by integrations whose user may read it (below)The same

Search results and /sitemap.xml are built separately from page rendering. Do not reason about them: test them, as part of the check at the end of this page.

What a listing shows

A listing is a template too, and it prints what it was written to print. A "Resources" page that loops over its children shows each child's title and summary to everyone who can open the listing. If the children are restricted records, members outside the group never see them in the loop. If the children are protected only by a check in their own template, the loop lists them all, and the summary it prints may be the part the client meant to keep for members.

The same goes for counts and teasers: "12 members-only reports" printed on a public page tells everyone how many there are, and a listing that shows a price shows it to everyone.

The same URL as JSON

Every page on the site answers Accept: application/json. The controller that would render the page returns its variables as JSON instead, without running the template. For a restricted record the refusal is the same as for the HTML page. For a record gated only in its template, the JSON is a full answer:

Shell A template-gated page, requested as JSON
curl -s -H 'Accept: application/json' https://www.example.com/members/annual-report

{"result":"success","data":{"record":{"name":"Annual report 2026","slug":"annual-report",
  "published":"2026-10-01T09:00:00+00:00","customFieldValues":[…],…},"page":{…}}}

The record's name, slug, dates and custom field values are all there, whatever the template would have hidden. That is another reason a template check is presentation, not protection (Template gating and route gating).

The API

The REST and GraphQL APIs are not where members see content: signed out they answer 401, and a signed-in member is refused on every route except a few built for visitors and members (A site member and a backend user). They are where integrations see it. An API key or OAuth token acts as its backend user, reads whatever that user may read, and, while Show unpublished is on, sees unpublished records as well. An administrator's credential reads everything.

So the API risk for gated content is credentials, not members. Give every integration its own backend user with only the permissions it needs, and know which keys and clients exist (API keys).

A synchronous job endpoint is different again: it answers whoever its settings admit and returns whatever the job puts in the response. An open endpoint must not return anything gated, or must check the caller first (Custom endpoints).

Checking a gated section

Run this before launch, and again after anyone changes the section's permissions or templates. Each step is done signed out first, then as a test member who is not in the group:

  1. Each gated URL: a 404, or the redirect to sign in.
  2. The same URLs as JSON: the same refusal, never a body with the record in it.
  3. Every listing that could show the section's records, and the home page: no gated titles, summaries or counts.
  4. The site's search, for a title you know is gated.
  5. /sitemap.xml: no gated URLs.
  6. Any synchronous job endpoint the front end calls: nothing gated in the response.

Then, as a member in the group, confirm the content is there. Finally, list the site's API keys and OAuth clients and confirm each one's user can read only what the integration needs.

Shell Steps 1 and 2, signed out, for a list of gated paths
for path in /members/annual-report /members/price-list; do
  html=$(curl -s -o /dev/null -w '%{http_code}' "https://www.example.com$path")
  json=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/json' "https://www.example.com$path")
  echo "$path html=$html json=$json"
done

Expect 404 for both. Where Access Denied redirects signed-out visitors to the sign-in page, expect 302 for the HTML request and 401 for the JSON one. A 200 on either means something is not restricted as expected: investigate it.

Go deeper

Was this page helpful?

Your answer goes to the docs team.