How-to library · Developer docs

How-to library

Task-shaped pages: a title that starts with a verb, the situation in a few lines, the steps, the working code in both engines, and why this way rather than another. Found from a search, done, and left.

Call the REST API from the browser with the CSRF token

A same-origin script sends the session cookie by itself; for a signed-in visitor the platform also wants the session's CSRF token on every write. Put _csrfToken in a meta tag, send it as X-Csrf-Token, and keep that page out of the cache.

Context

Context. A script on the site's own pages can call /cdna-api/ with the visitor's session cookie; the browser sends it automatically, because the call is same-origin. For a signed-in visitor, the platform then requires the session's CSRF token on every POST, PUT, PATCH and DELETE, in an X-Csrf-Token header or a _csrf body field. Templates receive it as _csrfToken, and only when someone is signed in. Two limits decide whether this how-to applies at all: signed-in site members are refused on everything except the visitor routes (cart, checkout, form submission, their own profile), so in practice this is a member at checkout or a backend user running a tool on the front end; and a request carrying an Authorization header skips the check, so none of this applies to API keys.

Steps

  1. In the base template, write the token into a <meta> tag when it exists.
  2. On any page whose script writes through the API, turn off the page cache. The platform stores only anonymous views, but it can serve that stored copy to a signed-in visitor, and the stored copy has no token. A page that must stay cached can instead get the token from a fragment: logged-in state on a cached page shows it.
  3. In your script, read the tag and send its value as X-Csrf-Token on every request that is not a GET.
  4. Treat a missing tag as "not signed in" and do not send the request.
  5. Read failures from the JSON body's message and the x-reason response header, which says not logged in on a 401.

Code

Template code
{* base template, inside <head> *}
{if isset($_csrfToken)}
  <meta name="csrf-token" content="{$_csrfToken}">
{/if}

{* the checkout template, or any page that writes *}
{$_response->preventCaching()}
{# base template, inside <head> #}
{% if _csrfToken is defined %}
  <meta name="csrf-token" content="{{ _csrfToken }}">
{% endif %}

{# the checkout template, or any page that writes #}
{% do _response.preventCaching() %}
JavaScript
// api.js: one helper for every same-origin call
function csrfToken() {
  const meta = document.querySelector('meta[name="csrf-token"]');
  return meta ? meta.content : null;
}

export async function api(method, path, body) {
  const headers = { Accept: 'application/json' };
  if (method !== 'GET') {
    const token = csrfToken();
    if (!token) throw new Error('Not signed in');
    headers['X-Csrf-Token'] = token;
  }
  if (body !== undefined) headers['Content-Type'] = 'application/json';

  const response = await fetch('/cdna-api/' + path, {
    method,
    headers,
    credentials: 'same-origin',
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const data = await response.json().catch(() => null);
  if (!response.ok) {
    const error = new Error((data && data.message) || response.headers.get('x-reason') || response.statusText);
    error.status = response.status;
    error.data = data;
    throw error;
  }
  return data;
}

// a signed-in member applying a discount code; the checkout template
// renders the checkout's uuid into data-checkout on the form
const form = document.querySelector('#discount-form');
form.addEventListener('submit', async (event) => {
  event.preventDefault();
  try {
    await api('POST', 'checkout/' + form.dataset.checkout + '/discount-code', { code: form.elements.code.value });
    location.reload();
  } catch (error) {
    form.querySelector('.error').textContent = error.message;
  }
});

Why this and not that

Not an API key in the page: anything in a page is readable by every visitor, and a key acts as its backend user with none of the session's limits. Not a token hardcoded into a cached template: the token belongs to one session, so a cached copy hands one visitor's token to the next, and it stops matching as soon as the session changes. Not a _csrf body field when you control the request: the header works for every method and body type, including a DELETE with no body. And leave GET requests without the token: only POST, PUT, PATCH and DELETE need it, and a token in a URL ends up in logs and in the Referer of the next page.

Go deeper

Was this page helpful?

Your answer goes to the docs team.