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

CSRF on member forms

Every state-changing request from a signed-in member carries the session's CSRF token, or the platform refuses it with a 403. Where the token comes from on a member page, how forms and scripts send it, what the refusal looks like, and the one legitimate exception.

Who this is for. The theme and JavaScript developers writing member forms, and whoever reviews them. Afterwards every signed-in write carries the token, and a 403 can be traced to its cause in a minute. API clients with a token or key are covered on Authentication and authorisation.

The rule

The token is checked when all three are true:

  • someone is signed in;
  • the method is POST, PUT, PATCH or DELETE;
  • the request has no Authorization header.

It is checked on every frontend route that does not declare its own middleware, the member area, the profile and password routes, forms and the cart among them, and on the API's routes. A signed-out request is not checked, since there is no session to act on, and neither is a request carrying a bearer token or an API key. Do not invent local exceptions: a member form that fails the check is missing its token, so add the token rather than weakening the check.

Where the token comes from

Templates receive it as _csrfToken, and only when someone is signed in. It belongs to the visitor's PHP session, so it changes when the session does. Three consequences:

  • A template that can render signed out must not print it unguarded.
  • A page served from the page cache was rendered for an anonymous visitor and carries no token. A member form on a public page gets its token from a fragment (A cached shell with member fragments).
  • A token taken from a page an hour ago can fail now: if the member signed in again in another tab, or their PHP session expired, the session's token has changed.

Sending it

A form posted the ordinary way sends it as a hidden _csrf field. A PUT or DELETE travels as a POST with a _method field:

Template code A member form
<form method="post" action="{$_url->getPath('changePassword')}">
  <input type="hidden" name="_method" value="PUT">
  <input type="hidden" name="_csrf" value="{if isset($_csrfToken)}{$_csrfToken}{/if}">
  …
</form>
<form method="post" action="{{ _url.getPath('changePassword') }}">
  <input type="hidden" name="_method" value="PUT">
  <input type="hidden" name="_csrf" value="{{ _csrfToken ?? '' }}">
  …
</form>

A script sends it as an X-Csrf-Token header, which is read before the body field (Cookie or bearer token has the example). The how-to Call the REST API from the browser with the CSRF token shows the full pattern for scripts.

The refusal

A request without the token, or with the wrong one, is refused with a 403 and the message "Missing or invalid CSRF token":

  • A page request renders your site's errors/not-authorized.html, as a template, if the site has one, and the framework's plain page otherwise. Write one: it is what a member sees when a form fails for any of the reasons above, so explain that the page has expired and link back to it.
  • A request for JSON gets {"result": "error", "message": "Missing or invalid CSRF token"}.

To find the cause, check in this order: is the field or header there at all; was the page that produced the form served from the cache (its response had X-Cache-Status: hit); has the member signed in again since the page loaded. The answer is never to move the form to GET or to remove the check.

The one exception: a signed request from another system

A call from a payment provider, a CRM or any other system has no session and no token, so it can never pass a CSRF check, and it should not be pointed at a member route. Point it at a job's web event instead (its URL is under /cdna-api/webhook/, a route that keeps the module's old name), and have the job verify the sender's signature before any action runs. Securing it shows the check, including the engine it can be written in.

An endpoint like that is a deliberate, reviewed exception to the rule, so it gets a review before it is published. A second developer confirms that:

  1. the signature is computed over the raw request body with a shared secret that is kept in site data, not in the job;
  2. the check is the first condition, and a failed check stops the job before any other action;
  3. the web event accepts only the methods the sender uses;
  4. nothing the job logs or returns contains the secret, and its answer reveals nothing to a caller who failed the check.
Go deeper

Was this page helpful?

Your answer goes to the docs team.