# Authentication & API credentials

**Goal:** set up API credentials a real project can ship with: the right grant type, minimal [scopes](/start-here/glossary/#scope), correct API and [CORS](/start-here/glossary/#cors) settings, and storage and rotation that survive deployment.

## What you'll have when you're done

- An [API client](/start-here/glossary/#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

- The [auth quickstart](/source-cms/authenticate/quickstart/) completed, or an existing API client and one working token
- Admin access to your [site](/start-here/glossary/#site)'s admin UI (to manage API clients, JSON:API settings, and CORS)

## Steps

- [Create one API client per application](#create-one-api-client-per-application)
- [Pick the grant type](#pick-the-grant-type)
- [Select the minimum scopes](#select-the-minimum-scopes)
- [Check API operations and public access](#check-api-operations-and-public-access)
- [Configure CORS for browser apps](#configure-cors-for-browser-apps)
- [Store credentials safely](#store-credentials-safely)
- [Rotate credentials with zero downtime](#rotate-credentials-with-zero-downtime)

<Steps>

1. ### 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 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. ### 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_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](/source-cms/authenticate/quickstart/) walks through it end to end:

   ```bash
   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](/start-here/glossary/#drupal-api-client) automates: configured with the client ID and secret, it requests and refreshes `client_credentials` tokens for you (the [quickstart](/source-cms/authenticate/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:

   ```text
   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:

   ```bash
   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:

   ```bash
   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:

   <ConceptDiagram name="oauth-code-flow" height={659} />

   Exact request and response schemas for every grant are in the [authentication reference](/source-cms/reference/authentication/).

3. ### 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 `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](#when-something-goes-wrong). 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](/start-here/glossary/#jsonapi): 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](/source-cms/content-api/writing-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](/source-cms/canvas-components/)).

4. ### Check API operations and public access

   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.

   :::caution[Public access removes authentication]
   With public access enabled, anyone on the internet can read what the API exposes: no token, no client, no scope. Only enable it for content that is genuinely public, and keep `Allowed operations` on `Read-only` while it's on.
   :::

5. ### Configure CORS for browser apps

   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](/start-here/glossary/#environment) runtimes, build steps, CLIs) are unaffected by it.

6. ### 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](/source-cms/canvas-components/quickstart/) is a separate OAuth client with its own `CANVAS_*` variable names; it pushes components, not content, so it doesn't share this credential set.)

   ```bash
   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](/source-cms/deploy/external-ci/) 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 credentials with zero downtime

   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](/source-cms/authenticate/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.

</Steps>

## 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](https://docs.acquia.com/acquia-source/roles-and-permissions). The two systems meet in one place: content [workflows](/start-here/glossary/#workflow). 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](/source-cms/content-api/writing-content/#where-content-workflows-fit) shows what that looks like over the API.

## 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](#select-the-minimum-scopes) 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

- [Query content by type](/source-cms/content-api/quickstart/): put a correctly scoped token to work.
- [Authentication reference](/source-cms/reference/authentication/): exact endpoint schemas, header format, and the scope and error tables.
- [Deploy from your own CI/CD](/source-cms/deploy/external-ci/): set the same three variables as CI secrets for the build that renders content.
