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 07 · Security for member sites

Personal data in logs, emails and the browser

Well-meaning code causes most member-data incidents: a token in a URL, a whole record in a log line or a script tag, a password in an email. What must never appear, the redaction rules for logs, emails and jobs, and the rule for what a template sends to the browser.

Who this is for. Every developer on a member site, and the reviewer who signs it off. Afterwards you can review a template, a job or an email for personal data, and redact what does not need to be there.

What must never appear

In a log, an email, a URL, a job's payload or a page's source, never:

  • passwords, in any form, including "temporary" ones sent by email;
  • tokens and links that sign someone in: session tokens, API keys, OAuth tokens, password reset and sign-in links;
  • full payment details: card numbers, security codes, bank account numbers;
  • unmasked contact data where an id would do: email addresses, phone numbers, home addresses.

Logs

When something goes wrong, the platform's log entry records the request that caused it, including the fields a form posted. Your jobs and integrations log what you tell them to. So:

  • Keep personal data out of URLs. A query string is written in full to logs, browser history, analytics and the Referer header of the next request. Post personal data in a form body; put an id, not an email address, in a link.
  • Log ids, not people. A user id or a submission id identifies the case for whoever investigates, without copying the person into the log.
  • Mask what you must include. Where a log line has to show contact data, show enough to recognise it and no more.

A line that follows the rules:

Shell Redacted
2026-10-19T09:14:02Z password-reset-requested user=4312 email=s***@example.com ip=203.0.113.0/24 result=sent

And the same event, unredacted, as it should never be written:

Shell Never like this
2026-10-19T09:14:02Z reset requested for sam.example@example.com (Sam Example, 0400 000 000) link=https://www.example.com/token-login?t=…

The second line contains a working sign-in link: anyone who can read the log can become that member until the link expires. Where the logs are lists who can read which log.

Jobs

A job run keeps each action's data and output, and anyone who can open the job's run log can read them. Treat a job's payload as a log:

  • Send an external system the fields it needs, not the whole record. A CRM that needs a name and an email address does not need the person's addresses and custom fields.
  • Never pass a password, a reset link or a token through a job.
  • Keep shared secrets for signing and verifying requests in site data, not in the job's templates, where every run log would show them.

Emails

  • Never email a password. Send a reset link instead; the platform's forgotten-password flow does.
  • Treat an email with a sign-in or reset link as a credential, because it is one: the link signs the reader in. Do not copy such emails to a shared inbox, attach them to tickets or forward them.
  • Put in an email only what the recipient needs. A confirmation can say "your address ending 42 Smith Street" without repeating the whole address.
  • Send from the client's authenticated domain, so the reset email reaches the inbox rather than the spam folder (The reset email).

What reaches the browser

The rule for templates and client-side state: send only what the page must render. Everything in the page source is readable by the visitor, by every script on the page, analytics and chat widgets included, and by anyone the page is copied to.

The common over-serialisation is a whole record dropped into a script for a front-end component that needed one field. On a member page, the user record printed whole carries the username, the user type and status, the ids of the member's groups and API keys and login tokens, and their custom field values; the component wanted a first name. Build the object you need instead:

Template code On a member page: one field, not the record
{* Not this: window.member = {$user|json_encode}; *}
{$memberState = ['firstName' => $user->getPerson()->getFirstName()]}
<script nonce="{$_inlineScriptNonce}">
  window.member = {$memberState|json_encode};
</script>
{# Not this: window.member = {{ user|json_encode }}; #}
{% set memberState = {'firstName': user.getPerson().getFirstName()} %}
<script nonce="{{ _inlineScriptNonce }}">
  window.member = {{ memberState|json_encode }};
</script>

Two more ways data reaches the browser without being printed:

  • The JSON representation. Any page requested with Accept: application/json answers with the variables its controller assigned. A member's own pages answer with their own records, which is acceptable. A public page must not be handed anything private in the first place (The same URL as JSON).
  • The page cache. A public page that printed something personal for one visitor would be wrong for the next; anything personal belongs on a page that is never stored, or in a fragment (Member pages and the page cache).

Checks

  1. Search the templates for json_encode applied to a record, and replace each with an object built from the fields the page needs.
  2. Read the source of a member page, signed in as a test member, for anything the page does not display.
  3. Read the run log of every job that handles people, and the bodies its HTTP requests send.
  4. Trigger each email the site sends and read it as the recipient would.
  5. Search links and redirects for email addresses or tokens in query strings.
Go deeper

Was this page helpful?

Your answer goes to the docs team.