Skip to content

Goal: make one authenticated call to your site’s Content API from your terminal.

When you need this. API access is authenticated by default; a site can opt into anonymous public reads for genuinely public content. You need credentials to:

  • read content on a site that hasn’t enabled public access (the default)
  • create, update, or delete content, on any site (writing content)
  • read unpublished drafts, for example in preview flows
  • guarantee fresh reads: anonymous responses are CDN-cached per URL, authenticated requests bypass that cache
  • An API client (client ID + secret) for your site.
  • A .env file with the three canonical DRUPAL_* variable names every later step reuses.
  • One successful authenticated request, made with the Drupal API Client, plain JavaScript, or curl, that proves your credentials work.
  • An Acquia account with admin access to a Source CMS site: step 1 needs the API > API clients menu in the site’s admin UI, which editor-level accounts don’t see (no site? a self-serve Cloud Platform free trial gets you building on Acquia the same day, though its Drupal 11 application is the Cloud Platform path, not a Source CMS site. For a Source CMS site, request a Source demo or see other ways to get one)
  • Node.js 20.6 or later for the JSON:API Client and JavaScript paths, or curl (preinstalled on macOS and Linux)
  1. In your site’s admin UI, go to API > API clients and select Add API client. Give it a name (for example local-dev) and grant it the scope content:administer: the scope that governs content over the API. Scopes are granted here, on the client; the token request in step 3 doesn’t name any.

    Copy the client ID and client secret shown. The secret is displayed once; store it now.

  2. These three variable names are the canon; the get-content and deploy guides use them unchanged:

    .env
    DRUPAL_SITE_URL=https://your-site.example.com
    DRUPAL_CLIENT_ID=9f8e7d6c-0000-0000-0000-000000000000
    DRUPAL_CLIENT_SECRET=your-client-secret

    DRUPAL_SITE_URL is the address you use to reach the site in a browser, scheme included, with no path and no trailing slash. Add .env to .gitignore if it isn’t there already.

  3. The credentials buy an OAuth 2.0 access token via the client_credentials grant, and the token authenticates a call to the JSON:API root. Pick how you want to do the exchange; your choice stays selected as you move between pages:

    The Drupal API Client does the token exchange for you: it requests a token from your credentials on first call and refreshes it when it expires. Install it, then save and run this script:

    Terminal window
    npm install @drupal-api-client/json-api-client
    auth.mjs
    import { JsonApiClient } from
    "@drupal-api-client/json-api-client";
    const client = new JsonApiClient(
    process.env.DRUPAL_SITE_URL,
    {
    apiPrefix: "api", // Source CMS serves JSON:API at /api
    authentication: {
    type: "OAuth",
    credentials: {
    grantType: "client_credentials",
    clientId: process.env.DRUPAL_CLIENT_ID,
    clientSecret: process.env.DRUPAL_CLIENT_SECRET,
    },
    },
    },
    );
    const { response, error } = await client.fetch(
    `${process.env.DRUPAL_SITE_URL}/api`,
    { headers: { Accept: "application/vnd.api+json" } },
    );
    // response is null whenever error is set.
    if (error) throw error;
    console.log(response.status);
    console.log(JSON.stringify(await response.json(), null, 2));
    Terminal window
    node --env-file=.env auth.mjs

    There is no token step: the client negotiated one behind the scenes and will keep doing so as tokens expire. client.fetch resolves to a response or an error, never both, so the error check is what turns an unreachable DRUPAL_SITE_URL into the real fetch failed / ENOTFOUND message instead of TypeError: Cannot read properties of null (reading 'status').

    Whichever path you took, a 200 response returns JSON listing your site’s available resource endpoints; a real site lists fifteen-plus links (files, media types, taxonomies, menus) around the ones you care about, which start with node-- (shown trimmed and formatted):

    {
    "jsonapi": {
    "version": "1.1",
    "meta": {
    "links": {
    "self": {
    "href": "http://jsonapi.org/format/1.1/"
    }
    }
    }
    },
    "data": [],
    "meta": {
    "links": {
    "me": {
    "meta": {
    "id": "cbad0cdf-0000-0000-0000-000000000000"
    }
    }
    }
    },
    "links": {
    "file--file": {
    "href": "https://your-site.example.com/api/file/file"
    },
    "media--image": {
    "href": "https://your-site.example.com/api/media/image"
    },
    "node--article": {
    "href": "https://your-site.example.com/api/node/article"
    },
    "node--page": {
    "href": "https://your-site.example.com/api/node/page"
    },
    "…": "…"
    }
    }

    Two proofs the token worked, whatever your site’s settings:

    • On a site whose Public access setting (under API > JSON:API) is No, the same request without the Authorization header (or with a bad token) returns 401 Unauthorized; the token is what turns the 401 into a 200.
    • With Public access set to Yes, reads succeed even without a token, but the meta.links.me block above appears only on authenticated requests. If you see it, your token was used.

    The auth guide covers the public-access toggle and its security trade-off.

Your site issued you an OAuth 2.0 access token in exchange for the API client’s ID and secret: the client_credentials grant, made for server-to-server calls where no user is present. The request named no scopes; the token automatically gets the ones you selected on the client in step 1 (content:administer). Every Content API request carries that token in an Authorization: Bearer <token> header. Tokens live 300 seconds (5 minutes; see expires_in). The Drupal API Client requests and refreshes tokens for you; in the JavaScript and curl paths your code requests a new one when the old one expires.

{"error":"invalid_client","error_description":"Client authentication failed"} (401 from /oauth/token)

means the client ID or secret is wrong. Re-copy both from API > API clients; if the secret is lost, create a new client, since the secret is shown only once.

401 Unauthorized on the API call itself

means the token is missing, malformed, or expired. Check the header is exactly Authorization: Bearer <token> and request a fresh token; they expire after expires_in seconds. More cases in the auth guide’s troubleshooting.

{"error":"invalid_scope","error_description":"The requested scope is invalid, unknown, or malformed","hint":"Check the `content:read` scope"} (400 from /oauth/token)

means the token request named a scope that doesn’t exist or isn’t selected on the client. The commands above send no scope parameter at all (the token automatically gets the client’s selected scopes), so if you added one, remove it.

403 Forbidden

means the token is valid but the client lacks the scope the request needs. Scopes are granted on the client, not in the token request: confirm content:administer is selected at API > API clients, then request a fresh token. A client without content:administer can’t write content, and with Public access set to Yes, reads succeed even without a token, so a missing scope typically surfaces on writes.

SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

from the JSON:API Client means it called the wrong base path and got an HTML page back: the client’s default is /jsonapi, but Source CMS serves the API at /api. Construct the client with apiPrefix: "api", as in step 3.

curl: (3) URL rejected or a request to https:///oauth/token

means the env vars never loaded. Run set -a; source .env; set +a in the same shell, then echo $DRUPAL_SITE_URL to confirm. In the Node.js paths the same mistake surfaces as TypeError: Failed to parse URL from undefined/oauth/token (JavaScript) or Error: baseUrl is required (JSON:API Client); run the script with --env-file=.env from the directory holding .env.

Was this page helpful?