Skip to content

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.

  • 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.
  • 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)
  1. 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 clients and select Add 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, the Client Credentials checkbox 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).

  2. 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_credentials Server-to-server calls where no user is present: builds, backends, CLIs /oauth/token
    authorization_code A user is present and authorizes access to their account /oauth/authorize, then /oauth/token
    refresh_token Long-lived sessions: get a new access token when one expires, without user interaction /oauth/token

    client_credentials is 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 scope parameter: 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_credentials tokens 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_code is 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=code

    Then 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 the authorization_code flow (not client_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.

  3. Scopes are checkboxes on the API client form, and the client’s selected scopes are the token’s defaults. A token request without a scope parameter 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’s scope parameter can only narrow the token to a subset of the selected scopes; naming an unknown or unselected scope fails with the invalid_scope error. So least privilege lives on the client: select the fewest checkboxes the application needs.

    The full scope vocabulary:

    Scope Governs
    content:administer Content CRUD over the JSON:API: create, update, and delete entries. There is no read-only content scope.
    content_type:administer Content types (bundles) themselves
    content_type:administer_fields Fields on content types
    page_template:administer Page templates
    media:administer Media entries
    media_type:administer Media types
    taxonomy:administer Taxonomy vocabularies and terms
    taxonomy:administer_fields Fields on taxonomy terms
    menu:administer Menus
    site_settings:administer Site settings
    canvas:page:read Read Canvas pages
    canvas:page:create Create Canvas pages
    canvas:page:edit Edit Canvas pages
    canvas:page:delete Delete Canvas pages
    canvas:page_region Canvas page regions
    canvas:content_template Canvas content templates
    canvas:asset_library Drupal Canvas CLI (@drupal-canvas/cli): asset library access for downloading and uploading components
    canvas:js_component Drupal Canvas CLI (@drupal-canvas/cli): JS component access for downloading and uploading components
    canvas:brand_kit Canvas brand kits
    canvas:media:view View media from Canvas
    canvas:media:image:create Create images from Canvas
    member Basic member access

    For a client that reads and writes content, select content:administer and nothing else. The canvas:* 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_library and canvas:js_component belong together on a dedicated client for the Canvas CLI (see the Canvas components guide).

  4. The JSON:API is enabled by default. Two settings under API > JSON:API control who can do what:

    Allowed operations. GET is always enabled; POST (create), PATCH (update), and DELETE are opt-in. Select Read-only (GET only) or Read and write (all operations on all resources), then Save configuration. If nothing writes through the API, leave it read-only; a leaked content:administer token can’t modify anything the API won’t accept.

    Public access. By default, API access requires authentication. To serve content anonymously, set Public access to Yes and save.

  5. CORS is enabled by default, permitting access from any origin (verified: a preflight returns access-control-allow-origin: * with GET, POST, DELETE, PATCH and the authorization header 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:

    1. In the left sidebar, click API > CORS configuration.
    2. In the Allowed origins field, add the IPs or domains of your front-end applications.
    3. Click Save configuration.

    CORS only governs browsers. Server-side calls (environment runtimes, build steps, CLIs) are unaffected by it.

  6. 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.com
    DRUPAL_CLIENT_ID=9f8e7d6c-0000-0000-0000-000000000000
    DRUPAL_CLIENT_SECRET=your-client-secret

    Do:

    • Locally: keep them in .env, and make sure .env is 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 .env or 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 like NEXT_PUBLIC_ or PUBLIC_ 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).
  7. Rotate in this order (new first, revoke last) and nothing goes down:

    1. 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.
    2. Deploy the new credentials. Update DRUPAL_CLIENT_ID and DRUPAL_CLIENT_SECRET in 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.
    3. Verify. Request a token with the new credentials and make one authenticated call, as in the quickstart.
    4. 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.

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.

{"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.

Was this page helpful?