Authentication & API credentials
Goal: set up API credentials a real project can ship with: the right grant type, minimal scopes, correct API and CORS settings, and storage and rotation that survive deployment.
What you’ll have when you’re done
Section titled “What you’ll have when you’re done”- An API client per application, configured with the grant type and the minimum scopes each one needs.
- API operations, public access, and CORS settings that match how your app actually calls the API.
- Credentials stored under the same three variable names locally and deployed, with a rotation procedure that causes no downtime.
Prerequisites
Section titled “Prerequisites”- The auth quickstart completed, or an existing API client and one working token
- Admin access to your site’s admin UI (to manage API clients, JSON:API settings, and CORS)
- Create one API client per application
- Pick the grant type
- Select the minimum scopes
- Check API operations and public access
- Configure CORS for browser apps
- Store credentials safely
- Rotate credentials with zero downtime
-
Create one API client per application
Section titled “Create one API client per application”Every site provides a default API client, but create a dedicated client for each application that talks to the site: one for your Next.js app, another for a mobile app. Separate clients keep scopes minimal per app and let you rotate one application’s credentials without touching the others.
In your site’s admin UI, go to
API > API clientsand selectAdd API client. The settings that matter:Confidential Client: whether the application can securely store a secret. Check it for server-side apps; leave it unchecked only for browser or mobile apps, which cannot keep a secret.Allowed Scopes: which API capabilities the client can access. Grant only what the app needs (see step 3).Grant types: which flows the client may use (for example, theClient Credentialscheckbox for server-to-server use).Redirect URIs: valid return URIs, required for the authorization code flow.3rd Party: whether the client is developed by a third party.Image Styles: allow or restrict access to image derivatives.
Save the client to generate its client ID and client secret. The secret is displayed once. Store it immediately (step 6).
-
Pick the grant type
Section titled “Pick the grant type”All three grants exchange credentials for an access token at
POST /oauth/token; the authorization code flow additionally starts at/oauth/authorize.Grant type Use when Endpoints client_credentialsServer-to-server calls where no user is present: builds, backends, CLIs /oauth/tokenauthorization_codeA user is present and authorizes access to their account /oauth/authorize, then/oauth/tokenrefresh_tokenLong-lived sessions: get a new access token when one expires, without user interaction /oauth/tokenclient_credentialsis the simplest flow: send the client ID and secret directly, get a token back. Access is based on the client’s permissions, not a user’s. The auth quickstart walks through it end to end:Terminal window 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"No
scopeparameter: the token gets the scopes selected on the client (see step 3).This is the one flow the Drupal API Client automates: configured with the client ID and secret, it requests and refreshes
client_credentialstokens for you (the quickstart’s JSON:API Client tab shows the setup). The two flows below involve a user or a stored refresh token, and their token handling is yours to implement.authorization_codeis a two-step flow with a user in the loop. First redirect the user to the site’s authorization page; they log in and grant permissions, and the site redirects back to your app with a temporary code:https://your-site.example.com/oauth/authorize?client_id=<client ID>&redirect_uri=<one of the client's Redirect URIs>&response_type=codeThen exchange that code for an access token, server-to-server:
Terminal window curl -s -X POST "$DRUPAL_SITE_URL/oauth/token" \-H "Content-Type: application/x-www-form-urlencoded" \-d "grant_type=authorization_code" \-d "code=<authorization code from the redirect>" \-d "client_id=$DRUPAL_CLIENT_ID" \-d "client_secret=$DRUPAL_CLIENT_SECRET" \-d "redirect_uri=<the same redirect URI>"refresh_token: refresh tokens are provided alongside access tokens in theauthorization_codeflow (notclient_credentials, which requests a fresh token instead) and have a longer lifetime. They let you keep access token lifetimes short (better security) while a session stays signed in: when the access token expires, exchange the refresh token for a new one, with no user interaction needed:Terminal window curl -s -X POST "$DRUPAL_SITE_URL/oauth/token" \-H "Content-Type: application/x-www-form-urlencoded" \-d "grant_type=refresh_token" \-d "refresh_token=<refresh token>" \-d "client_id=$DRUPAL_CLIENT_ID" \-d "client_secret=$DRUPAL_CLIENT_SECRET"The two flows chain into one session lifecycle: the user authorizes once, and the refresh loop keeps the session alive without them:
Exact request and response schemas for every grant are in the authentication reference.
-
Select the minimum scopes
Section titled “Select the minimum scopes”Scopes are checkboxes on the API client form, and the client’s selected scopes are the token’s defaults. A token request without a
scopeparameter succeeds, and the token carries every scope selected on the client (the form itself says selecting scopes here means there is no need to specify the scope parameter). A request’sscopeparameter can only narrow the token to a subset of the selected scopes; naming an unknown or unselected scope fails with theinvalid_scopeerror. So least privilege lives on the client: select the fewest checkboxes the application needs.The full scope vocabulary:
Scope Governs content:administerContent CRUD over the JSON:API: create, update, and delete entries. There is no read-only content scope. content_type:administerContent types (bundles) themselves content_type:administer_fieldsFields on content types page_template:administerPage templates media:administerMedia entries media_type:administerMedia types taxonomy:administerTaxonomy vocabularies and terms taxonomy:administer_fieldsFields on taxonomy terms menu:administerMenus site_settings:administerSite settings canvas:page:readRead Canvas pages canvas:page:createCreate Canvas pages canvas:page:editEdit Canvas pages canvas:page:deleteDelete Canvas pages canvas:page_regionCanvas page regions canvas:content_templateCanvas content templates canvas:asset_libraryDrupal Canvas CLI ( @drupal-canvas/cli): asset library access for downloading and uploading componentscanvas:js_componentDrupal Canvas CLI ( @drupal-canvas/cli): JS component access for downloading and uploading componentscanvas:brand_kitCanvas brand kits canvas:media:viewView media from Canvas canvas:media:image:createCreate images from Canvas memberBasic member access For a client that reads and writes content, select
content:administerand nothing else. Thecanvas:*family is deliberately granular (per-operation for pages, separate scopes for media view and image creation), so a Canvas-facing client can be scoped to exactly the operations it performs.canvas:asset_libraryandcanvas:js_componentbelong together on a dedicated client for the Canvas CLI (see the Canvas components guide). -
Check API operations and public access
Section titled “Check API operations and public access”The JSON:API is enabled by default. Two settings under
API > JSON:APIcontrol who can do what:Allowed operations. GET is always enabled; POST (create), PATCH (update), and DELETE are opt-in. SelectRead-only(GET only) orRead and write(all operations on all resources), thenSave configuration. If nothing writes through the API, leave it read-only; a leakedcontent:administertoken can’t modify anything the API won’t accept.Public access. By default, API access requires authentication. To serve content anonymously, setPublic accesstoYesand save. -
Configure CORS for browser apps
Section titled “Configure CORS for browser apps”CORS is enabled by default, permitting access from any origin (verified: a preflight returns
access-control-allow-origin: *withGET, POST, DELETE, PATCHand theauthorizationheader allowed). Your local dev server can call the API from the browser with zero setup. For production, restrict it to the origins that actually need it:- In the left sidebar, click
API > CORS configuration. - In the
Allowed originsfield, add the IPs or domains of your front-end applications. - Click
Save configuration.
CORS only governs browsers. Server-side calls (environment runtimes, build steps, CLIs) are unaffected by it.
- In the left sidebar, click
-
Store credentials safely
Section titled “Store credentials safely”Use the same three variable names everywhere you call the Content API or MCP server. They’re the canonical names, and identical names mean the same code reads its credentials locally and deployed with no environment-specific branches. (The Canvas CLI is a separate OAuth client with its own
CANVAS_*variable names; it pushes components, not content, so it doesn’t share this credential set.)Terminal window DRUPAL_SITE_URL=https://your-site.example.comDRUPAL_CLIENT_ID=9f8e7d6c-0000-0000-0000-000000000000DRUPAL_CLIENT_SECRET=your-client-secretDo:
- Locally: keep them in
.env, and make sure.envis in.gitignore. - Deployed: set the same names in your hosting platform’s environment variables or secret store, never in the repository. For Acquia hosting they go in as CI secrets: deploy from your own CI/CD shows the three of them in the secret table and the build step’s
env:. - Request tokens server-side and pass only rendered content (or the short-lived token, when unavoidable) to the browser.
Don’t:
- Never commit
.envor hardcode the secret in source. - Never put the client secret in client-side code. This is the exposure failure mode: everything shipped to the browser is public, so anyone can open the network tab or view source, extract
DRUPAL_CLIENT_SECRET, and mint tokens with every scope that client allows. Framework prefixes likeNEXT_PUBLIC_orPUBLIC_deliberately expose variables to the browser; never apply them to these three. - Never share one client’s credentials across applications (see step 1: it makes rotation and least-scope impossible).
- Locally: keep them in
-
Rotate credentials with zero downtime
Section titled “Rotate credentials with zero downtime”Rotate in this order (new first, revoke last) and nothing goes down:
- Create the new client. At
API > API clients, add a client with the same grant types and scopes as the old one. Copy its ID and secret. - Deploy the new credentials. Update
DRUPAL_CLIENT_IDandDRUPAL_CLIENT_SECRETin each environment’s secret store and redeploy or restart. Because the variable names are identical everywhere, this is a value change, not a code change. - Verify. Request a token with the new credentials and make one authenticated call, as in the quickstart.
- Revoke the old client. Delete it at
API > API clients. Anything still using the old credentials now fails, which is exactly the signal that an environment was missed in step 2.
Do the same on a schedule, and immediately whenever a secret may have leaked.
- Create the new client. At
Scopes are not team roles
Section titled “Scopes are not team roles”Scopes govern what an application may do with the API. What the people on your team may do (sign in to the site’s admin UI, create content, approve and publish it) is a separate system: user roles and permissions, assigned per site. Source CMS ships two default roles, Admin and Member, plus site-level custom roles for editorial processes. Selecting a scope on an API client grants nothing to any user, and granting a user a role changes nothing about what any token can do.
Roles are administered in the admin UI; see the roles and permissions page on docs.acquia.com. The two systems meet in one place: content workflows. Transitions between workflow states (for example Draft to Published) are gated by permissions, not scopes, so a write that is fully within a token’s scopes can still be refused. The writes guide shows what that looks like over the API.
When something goes wrong
Section titled “When something goes wrong”{"error":"invalid_client","error_description":"Client authentication failed"} (401 from /oauth/token)
the client ID or secret sent to /oauth/token is wrong. Re-copy both from API > API clients; if the secret is lost, create a new client (the secret is shown only once).
401 Unauthorized with error="access_denied", error_description="Access token could not be verified" in the www-authenticate header, on an API request that worked before
the access token outlived its expires_in lifetime of 300 seconds (5 minutes). The JSON explanation lives in that response header (verified); the response body is an HTML page, so code that only parses the body sees nothing useful. Request a fresh token from /oauth/token and retry, and have your code re-request tokens automatically rather than caching one forever.
401 Unauthorized on an API request that has never worked
the Authorization header is missing, malformed, or carries a token this site did not issue. Send exactly Authorization: Bearer <token> (one space after Bearer) with a token issued by the same site you’re calling.
{"title":"Forbidden","status":"403","detail":"The 'administer nodes' permission is required."} as a JSON:API error document
the token is valid but doesn’t carry the scope the request needs: writing content with a token from a client that doesn’t have content:administer selected, for example, or narrowing the scope parameter below what the request requires. The detail names the missing permission (verified: the permission a scope maps to, not the scope name itself). Select the scope on the API client at API > API clients, then request a new token; existing tokens don’t gain scopes retroactively.
{"error":"invalid_scope","error_description":"The requested scope is invalid, unknown, or malformed","hint":"Check the `content:read` scope"} (400 from /oauth/token)
the token request’s scope parameter names a scope that doesn’t exist or isn’t selected on the client; the hint names the offending scope, and a scope parameter can only narrow to a subset of the client’s selected scopes. Omit the parameter (the token then carries every scope selected on the client), or name only scopes from the vocabulary in step 3 that the client has selected.
…has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. in the browser console (the exact wording varies by browser)
the site’s allowed origins have been restricted at API > CORS configuration and the browser origin making the request isn’t one of them (CORS is open by default). Add the origin under Allowed origins at API > CORS configuration, or move the call server-side, where CORS doesn’t apply.
Next steps
Section titled “Next steps”- Query content by type: put a correctly scoped token to work.
- Authentication reference: exact endpoint schemas, header format, and the scope and error tables.
- Deploy from your own CI/CD: set the same three variables as CI secrets for the build that renders content.
Was this page helpful?
What went wrong?
Still stuck? Contact Acquia Support (opens in a new tab)