# Authentication endpoints & headers

Lookup material for authenticating against a [site](/start-here/glossary/#site)'s API. For instruction, see the [auth quickstart](/source-cms/authenticate/quickstart/) (first token in 10 minutes) and the [auth guide](/source-cms/authenticate/guide/) (grant choice, scoping, storage, rotation). Both endpoints are also described in the machine-readable [OpenAPI 3.1 spec](/openapi/acquia-content-api.yaml) under the `Authentication` tag, published at a stable URL for code generators and API collections.

All paths are relative to the site base URL (`DRUPAL_SITE_URL`, no trailing slash). Credentials are an [API client](/start-here/glossary/#api-client)'s ID and secret, created in your site's admin UI at `API > API clients`. These endpoints exist on Source CMS sites. A headless site on Cloud Platform authenticates against your own Drupal application's OAuth setup, which you configure and operate yourself.

## Endpoints

How the two endpoints cooperate in the `authorization_code` grant; the other two grants skip `/oauth/authorize` and call `POST /oauth/token` directly:

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

### `GET /oauth/authorize`

Starts the `authorization_code` grant: the user's browser is redirected here, the user logs in and grants permissions, and the site redirects back with a temporary code. Not used by the other grants.

Query parameters:

| Parameter | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `response_type` | string | yes | none | The literal value `code` |
| `client_id` | string | yes | none | The API client's ID |
| `redirect_uri` | string | yes | none | Must match one of the client's configured `Redirect URIs` |
| `scope` | string | no | every scope selected on the client | Space-separated subset of the client's selected [scopes](/start-here/glossary/#scope) |

Response: a redirect to `redirect_uri` carrying the temporary authorization code, which is then exchanged at `POST /oauth/token` with `grant_type=authorization_code`.

Used in: [auth guide, step 2](/source-cms/authenticate/guide/#pick-the-grant-type).

### `POST /oauth/token`

Issues access tokens for all three grant types. Request body is form-encoded:

```text
Content-Type: application/x-www-form-urlencoded
```

Request parameters per grant type:

#### `grant_type=client_credentials`

Server-to-server; access is based on the client's permissions, not a user's.

| Parameter | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `grant_type` | string | yes | none | The literal value `client_credentials` |
| `client_id` | string | yes | none | The API client's ID |
| `client_secret` | string | yes | none | The API client's secret |
| `scope` | string | no | every scope selected on the client | Space-separated subset of the client's selected scopes |

Example exchange (token redacted):

```bash
curl -s -X POST "$DRUPAL_SITE_URL/oauth/token" \
  -d "grant_type=client_credentials" \
  -d "client_id=$DRUPAL_CLIENT_ID" \
  -d "client_secret=$DRUPAL_CLIENT_SECRET"
```

```json
{
  "token_type": "Bearer",
  "expires_in": 300,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUz…(redacted)"
}
```

Used in: [auth quickstart](/source-cms/authenticate/quickstart/), [auth guide, step 2](/source-cms/authenticate/guide/#pick-the-grant-type).

#### `grant_type=authorization_code`

Exchanges the code from `GET /oauth/authorize` for a token. All parameters are required strings with no defaults:

| Parameter | Description |
| --- | --- |
| `grant_type` | The literal value `authorization_code` |
| `code` | The authorization code from the redirect |
| `client_id` | The API client's ID |
| `client_secret` | The API client's secret |
| `redirect_uri` | The same redirect URI used at `/oauth/authorize` |

Used in: [auth guide, step 2](/source-cms/authenticate/guide/#pick-the-grant-type).

#### `grant_type=refresh_token`

Exchanges a refresh token for a new access token, without user interaction. All parameters are required strings with no defaults:

| Parameter | Description |
| --- | --- |
| `grant_type` | The literal value `refresh_token` |
| `refresh_token` | The refresh token |
| `client_id` | The API client's ID |
| `client_secret` | The API client's secret |

Used in: [auth guide, step 2](/source-cms/authenticate/guide/#pick-the-grant-type).

#### Response schema (all grant types)

Success is `200` with JSON:

```json
{
  "token_type": "Bearer",
  "expires_in": 300,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI…(redacted)"
}
```

| Field | Type | Meaning |
| --- | --- | --- |
| `token_type` | string | Always `Bearer`, use the header format below |
| `expires_in` | integer | Access token lifetime in seconds, counted from issuance |
| `access_token` | string | The token itself |
| `refresh_token` | string | Returned by the `authorization_code` and `refresh_token` grants. Never returned by `client_credentials` (absent from the captured response above, and confirmed in the OAuth server implementation): server-to-server clients re-request tokens with their credentials instead |

Failure is `401` with a JSON error body; see [Error codes](#error-codes).

## Request header

Every authenticated API request carries the access token in this exact header:

```text
Authorization: Bearer <token>
```

One space between `Bearer` and the token. Canonical example against the [JSON:API](/start-here/glossary/#jsonapi) root:

```bash
curl -s "$DRUPAL_SITE_URL/api" \
  -H "Accept: application/vnd.api+json" \
  -H "Authorization: Bearer $TOKEN"
```

Used in: [auth quickstart, step 3](/source-cms/authenticate/quickstart/#make-one-authenticated-request), every request in the [Content API reference](/source-cms/reference/content-api/).

## Scopes

Scopes are selected as checkboxes on the API client (`Allowed Scopes`) and are the token's defaults:

- A token request without a `scope` parameter carries every selected scope.
- A `scope` parameter may narrow to a subset of them.
- Naming an unknown or unselected scope returns the [`invalid_scope` error](#error-codes).

Scopes gate writes and admin surfaces, not published-content reads: a `GET` for published content succeeds with any valid token regardless of scope, and with no token at all when the site's `Public access` (`API > JSON:API`) is `Yes`. Reading *unpublished* content requires `content:administer`.

The full vocabulary:

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

## Error codes

This table matches the [auth guide's troubleshooting](/source-cms/authenticate/guide/#when-something-goes-wrong), which adds context per entry.

| Error | Cause | Fix |
| --- | --- | --- |
| `{"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`, on an API request that worked before; the explanation is in the `WWW-Authenticate` response header (`error="access_denied", error_description="Access token could not be verified"`), not the body | The access token outlived its `expires_in` lifetime: 300 seconds (5 minutes). | Request a fresh token from `/oauth/token` and retry; have your code re-request tokens automatically rather than caching one forever. |
| `401 Unauthorized`, on an API request that has never worked. **The body is HTML, not JSON**; the diagnostic is only in the `WWW-Authenticate` response header, e.g. (captured) `Bearer realm="OAuth", error="access_denied", error_description="The JWT string must have two dots"` for a malformed token | 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; read the header with `curl -i`. |
| ``{"error":"invalid_scope","error_description":"The requested scope is invalid, unknown, or malformed","hint":"Check the `content:read` scope"}``, `400` from `/oauth/token`; the `hint` names the offending scope | The token request's `scope` parameter names a scope that doesn't exist or isn't selected on the client. | Omit the `scope` parameter (the token then carries every scope selected on the client), or name only selected scopes from the [scope table](#scopes). |
| `403 Forbidden`, JSON:API error document whose `detail` names the missing permission (e.g. `The 'administer nodes' permission is required.`) | The token is valid but doesn't carry the scope the request needs, for example, writing content with a token from a client that doesn't have `content:administer` selected. | Select the scope on the API client at `API > API clients`, then request a new token. Existing tokens don't gain scopes retroactively. |
| `…blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.`, browser console | 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. |

## Token lifetimes

| Token | Lifetime |
| --- | --- |
| Access token | 300 seconds (5 minutes), reported per token as `expires_in`. |
| Authorization code | 300 seconds: the code from `GET /oauth/authorize` must be exchanged within 5 minutes (product configuration, confirmed 2026-07-03). |
| Refresh token | 1209600 seconds (14 days; product configuration, confirmed 2026-07-03). |

## Used in

- [Auth quickstart](/source-cms/authenticate/quickstart/): obtains a `client_credentials` token and makes the first authenticated call.
- [Auth guide](/source-cms/authenticate/guide/): chooses between these grant types and covers scoping, storage, and rotation.
- [Content API reference](/source-cms/reference/content-api/): the endpoints these tokens authorize.
