Guide 06 · Developer docs

The APIs: REST, GraphQL and custom endpoints

Every Core dna site answers three kinds of API request under /cdna-api: REST for everything, GraphQL for reads that span several entity types, and web events for endpoints you define yourself as jobs. Which fits which task, one worked request for each, and what is true of all three. Jobs were called hooks until the module was renamed Orchestration; the API's routes still use the old name.

L1–L3
Unit 05 · Credentials and access

The CSRF token, and the 403 on a POST

A write that works from the admin or with an API key answers 403 “Missing or invalid CSRF token” when a script on the site sends it. When the token is checked, which routes check it, where a page gets it, and the fix for each kind of client.

Who this is for. The JavaScript developer on a server-rendered or hybrid site, whose scripts write through the API with the visitor's session. Afterwards you can tell a CSRF refusal from the other 403s, put the token where your scripts can read it, and know which clients never need it.

The symptom

A POST, PUT or DELETE that works when you try it from the admin, or from a script with an API key, answers 403 when the same request comes from a script on the site:

JSON
{"result":"error","message":"Missing or invalid CSRF token"}

The message is the same whether the token was missing or wrong. Two other 403s look similar and are not this: “Authentication failed” is a credential the platform did not recognise, and “Token doesn't exist or is not valid” is an OAuth access token that has expired (Authentication and authorisation).

When the token is checked

A CSRF token (cross-site request forgery token) proves that a write sent with a session cookie came from a page the site itself served. The platform checks it only when all three are true:

  1. Someone is signed in, a site member or a backend user, through the session.
  2. The method writes: POST, PUT, PATCH or DELETE.
  3. The request has no Authorization header. An OAuth access token, an API key or Basic credentials skip the check, because a page on another site cannot make a browser send them.

The token is read from the X-Csrf-Token header, or, when there is no such header, from a _csrf field in the body. A request signed in by nobody is never checked; it meets the routes' own sign-in rules instead.

Which routes check it

CheckedNot checked
Every REST route that does not set its own rules, which is most of them; GraphQL; the checkout calls; custom entity record writes; the job API under /cdna-api/hooks; the product wishlist and product interests; the PayPal payment-method calls; and, on the site's own pages, the my-account forms and the OAuth consent screenThe cart; form submissions; sign-in, sign-out, registration and password reset; web events (/cdna-api/webhook/…)

So a cart that works and a checkout step that 403s, in the same script, is this check and nothing else.

Where a page gets the token

Templates receive the session's token as _csrfToken, and only when someone is signed in. Write it into the page once, in the base template, where one script module can read it:

Template code In the base template's <head>
{if isset($_csrfToken)}
  <meta name="csrf-token" content="{$_csrfToken|escape}">
{/if}
{% if _csrfToken is defined %}
  <meta name="csrf-token" content="{{ _csrfToken|e }}">
{% endif %}
JavaScript src/js/api.js
// The only place that knows the token: every write goes through send().
const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? null;

export async function send(method, path, body) {
  const headers = { Accept: 'application/json', 'Content-Type': 'application/json' };
  if (csrf) headers['X-Csrf-Token'] = csrf;
  const response = await fetch('/cdna-api/' + path, {
    method, headers, credentials: 'same-origin', body: JSON.stringify(body),
  });
  return { status: response.status, data: await response.json().catch(() => ({})) };
}

An HTML form posted the ordinary way, without a script, carries the token as a hidden field instead: <input type="hidden" name="_csrf" value="…">, filled from the same variable.

A cached page has no token

The page cache keeps one copy of a page for everyone, so a token written into it would be the wrong one for every other visitor, and a copy made for nobody signed in has none at all. On a page the cache serves, deliver the token in a fragment that is never cached and hand it to the script module from there (preventCaching() and fragments).

The fix for each client

ClientFix
A script on a page that is not cachedThe <meta> tag above, sent as X-Csrf-Token by the one module that makes requests: the how-to
A script on a cached pageThe token from an uncached fragment, passed to the module once: the fragment how-to
An HTML form posted without a scriptA hidden _csrf field
A server, a job, a mobile appAn OAuth access token (or an API key) in the Authorization header; the check does not apply

A token that worked and now fails

The token belongs to the session, so it changes when the session does. A token that worked an hour ago and fails now usually means the person signed out and in again, perhaps in another tab, and this page still holds the old one. Retrying the request sends the same stale token: reload the page, or fetch a fresh fragment, and ask the person to repeat what they did.

Go deeper

Was this page helpful?

Your answer goes to the docs team.