Quickstart
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
What you’ll have when you’re done
Section titled “What you’ll have when you’re done”- An API client (client ID + secret) for your site.
- A
.envfile with the three canonicalDRUPAL_*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.
Prerequisites
Section titled “Prerequisites”- An Acquia account with admin access to a Source CMS site: step 1 needs the
API > API clientsmenu 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)
-
Create an API client
Section titled “Create an API client”In your site’s admin UI, go to
API > API clientsand selectAdd API client. Give it a name (for examplelocal-dev) and grant it the scopecontent: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.
-
Put the credentials in a
Section titled “Put the credentials in a .env file”.envfileThese three variable names are the canon; the get-content and deploy guides use them unchanged:
.env DRUPAL_SITE_URL=https://your-site.example.comDRUPAL_CLIENT_ID=9f8e7d6c-0000-0000-0000-000000000000DRUPAL_CLIENT_SECRET=your-client-secretDRUPAL_SITE_URLis the address you use to reach the site in a browser, scheme included, with no path and no trailing slash. Add.envto.gitignoreif it isn’t there already. -
Make one authenticated request
Section titled “Make one authenticated request”The credentials buy an OAuth 2.0 access token via the
client_credentialsgrant, 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-clientauth.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 /apiauthentication: {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.mjsThere is no token step: the client negotiated one behind the scenes and will keep doing so as tokens expire.
client.fetchresolves to a response or an error, never both, so theerrorcheck is what turns an unreachableDRUPAL_SITE_URLinto the realfetch failed/ENOTFOUNDmessage instead ofTypeError: Cannot read properties of null (reading 'status').Exchange the credentials for a token with a
POSTto/oauth/token, then send the token in anAuthorization: Bearerheader. Save and run this script:auth.mjs const {DRUPAL_SITE_URL,DRUPAL_CLIENT_ID,DRUPAL_CLIENT_SECRET,} = process.env;const tokenResponse = await fetch(`${DRUPAL_SITE_URL}/oauth/token`,{method: "POST",headers: {"Content-Type": "application/x-www-form-urlencoded",},body: new URLSearchParams({grant_type: "client_credentials",client_id: DRUPAL_CLIENT_ID,client_secret: DRUPAL_CLIENT_SECRET,}),},);const { access_token, expires_in } =await tokenResponse.json();console.log(`token expires in ${expires_in}s`);const response = await fetch(`${DRUPAL_SITE_URL}/api`, {headers: {Accept: "application/vnd.api+json",Authorization: `Bearer ${access_token}`,},});console.log(response.status);console.log(JSON.stringify(await response.json(), null, 2));Terminal window node --env-file=.env auth.mjsThe first line printed is
token expires in 300s: your token, alive for 5 minutes.Load the file into your shell and exchange the credentials for a token:
Terminal window set -a; source .env; set +a # export every variable in .env into this shellcurl -s -X POST "$DRUPAL_SITE_URL/oauth/token" \-H "Content-Type: application/x-www-form-urlencoded" \-d "grant_type=client_credentials" \-d "client_id=$DRUPAL_CLIENT_ID" \-d "client_secret=$DRUPAL_CLIENT_SECRET"A successful response looks like this (the API returns it as one compact line; shown formatted):
{"token_type": "Bearer","expires_in": 300,"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9…(redacted)"}Store the token and call the API root:
Terminal window TOKEN=$(curl -s -X POST "$DRUPAL_SITE_URL/oauth/token" \-H "Content-Type: application/x-www-form-urlencoded" \-d "grant_type=client_credentials" \-d "client_id=$DRUPAL_CLIENT_ID" \-d "client_secret=$DRUPAL_CLIENT_SECRET" \| sed -n 's/.*"access_token": *"\([^"]*\)".*/\1/p')curl -s "$DRUPAL_SITE_URL/api" \-H "Accept: application/vnd.api+json" \-H "Authorization: Bearer $TOKEN"Whichever path you took, a
200response returns JSON listing your site’s available resource endpoints; a real site lists fifteen-pluslinks(files, media types, taxonomies, menus) around the ones you care about, which start withnode--(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 accesssetting (underAPI > JSON:API) isNo, the same request without theAuthorizationheader (or with a bad token) returns401 Unauthorized; the token is what turns the401into a200. - With
Public accessset toYes, reads succeed even without a token, but themeta.links.meblock 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.
- On a site whose
What just happened
Section titled “What just happened”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.
When something goes wrong
Section titled “When something goes wrong”{"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.
Next steps
Section titled “Next steps”- Query content by type: use this token to fetch real entries.
- First fetch: your
.envfile works there unchanged. - Authentication & API credentials guide: grant types, scopes, storage, and rotation for real projects.
Was this page helpful?
What went wrong?
Still stuck? Contact Acquia Support (opens in a new tab)