Skip to content

How-to guideFor front-end developers, module developersDrupal 11

Authentication and CORS

A decoupled client reading more than public content has to authenticate, and a browser client on its own origin has to clear CORS first. This page covers cookie versus token authentication, where the OAuth modules sit, and how to enable CORS in services.yml.

Let a front end on a different origin authenticate to Drupal and call JSON:API from the browser. By the end you understand when to use cookie authentication and when to use tokens, what core provides versus what you add from contrib, and you have CORS configured for your front end’s origin.

If your client only ever reads content the anonymous role can already view, you need neither authentication nor (for a same-origin server-side fetch) CORS. Reach for this page when the client is a browser app on another origin, or when it must read or write content beyond anonymous access.

  • A working read flow from JSON:API basics.
  • Write access to sites/default/services.yml (or sites/default/default.services.yml to start from the shipped defaults).
  • An understanding of access: users, roles, and permissions; authentication only identifies the account, access decides what it may do.

Core ships two authentication providers usable by an API client:

  • Cookie authentication is the provider behind the normal login form, the user.authentication.cookie service. The browser sends the session cookie automatically. It is the right choice when the front end shares a parent domain with Drupal (so the cookie is in scope) and you can carry the CSRF token that writes require. Cross-origin cookie auth needs supportsCredentials in CORS and a cookie configured to cross origins.
  • Basic authentication comes from the basic_auth core module (drush pm:install basic_auth), the basic_auth provider. It sends the username and password on every request. It is fine for server-to-server scripts and local testing over HTTPS, and wrong for a public browser client, which would have to hold a password.

Both plug into the authentication system through \Drupal\Core\Authentication\AuthenticationProviderInterface. A request with no recognized credentials runs as the anonymous user; authentication never grants access on its own, it only sets which account the access checks then run against.

Token authentication and the OAuth landscape

Section titled “Token authentication and the OAuth landscape”

For a public single-page or native app, neither shipping a password nor relying on a cross-site cookie is appropriate. The standard answer is OAuth 2 bearer tokens, and that capability lives in contrib (community-contributed modules you install separately), not in core:

  • Simple OAuth (drupal/simple_oauth) adds an OAuth 2 server: it issues access and refresh tokens and registers a bearer-token authentication provider that JSON:API requests then use with an Authorization: Bearer <token> header.
  • Consumers (drupal/consumers) models each client application as a consumer entity. Simple OAuth depends on it to scope tokens to a specific client.

Core does not ship either module and does not issue tokens itself. State this plainly to readers evaluating the stack: the JSON:API endpoint is core, the OAuth token server is a contrib addition you install with Composer (PHP’s dependency manager, the equivalent of npm: composer require drupal/simple_oauth). Once installed, the client exchanges credentials for a token at the module’s token endpoint and sends that token on every API request; Drupal authenticates the request and runs the same access checks as any other.

CORS (Cross-Origin Resource Sharing) is a browser policy. When JavaScript on http://localhost:3000 calls an API on https://my-site.ddev.site, the browser blocks reading the response unless the API returns headers permitting that origin. A server-side fetch (Node, curl) is not a browser and is not subject to CORS, which is why the Next.js quickstart fetches from server components without touching this setting.

Drupal ships CORS disabled. In default.services.yml the cors.config block sets enabled: false, and the http_middleware.cors service applies that config (%cors.config%) on every response. While it is off, Drupal sends no Access-Control-* headers and cross-origin browser requests fail in the browser, regardless of authentication.

Copy sites/default/default.services.yml to sites/default/services.yml if you have not already, then set the cors.config block. List your front end’s exact origin rather than *:

web/modules/custom/docs_snippets/headless_cors.services.yml
parameters:
cors.config:
enabled: true
# Request headers the browser may send. The CSRF token header is needed
# for cookie-authenticated writes, Authorization for Basic/Bearer auth.
allowedHeaders: ['x-csrf-token', 'authorization', 'content-type', 'accept']
allowedMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS']
# List your front end's exact origin. Never ship '*' together with
# supportsCredentials: true: Drupal then echoes back any request's
# Origin, so every website gets credentialed access.
allowedOrigins: ['http://localhost:3000']
allowedOriginsPatterns: []
exposedHeaders: false
# Cache preflight responses for 10 minutes.
maxAge: 600
# Required for cookie-based authentication across origins.
supportsCredentials: true

Rebuild the cache after editing the file: drush cache:rebuild. The settings map to response headers one for one: allowedOrigins to Access-Control-Allow-Origin, allowedMethods to Access-Control-Allow-Methods, allowedHeaders to Access-Control-Allow-Headers, and maxAge to how long the browser caches the preflight OPTIONS response.

Two pairings matter when the client sends credentials (a cookie or an Authorization header):

  • Set supportsCredentials: true so Drupal returns Access-Control-Allow-Credentials: true. Without it the browser discards an authenticated cross-origin response.
  • Never combine supportsCredentials: true with allowedOrigins: ['*']. Drupal does not answer that combination with the * wildcard: it echoes back whatever Origin the request carries, so every website on the internet passes the check. With a session cookie configured to cross origins, any page a logged-in user visits can then read their authenticated responses. List explicit origins, or an allowedOriginsPatterns regular expression anchored to domains you control.

Include x-csrf-token in allowedHeaders if the client uses cookie authentication for writes, and authorization if it sends bearer or basic credentials.

A preflight request to a JSON:API route returns the allow headers for your origin:

Terminal window
curl -i -X OPTIONS https://my-site.ddev.site/jsonapi/node/article \
-H 'Origin: http://localhost:3000' \
-H 'Access-Control-Request-Method: GET'

The response includes Access-Control-Allow-Origin: http://localhost:3000 and Access-Control-Allow-Credentials: true. If those headers are absent, the cache was not rebuilt or enabled is still false. To check cookie-authenticated writes, log in over JSON to store the session cookie in a file, then fetch a CSRF token with that cookie:

Terminal window
curl -c jar -H 'Content-Type: application/json' \
-d '{"name":"editor","pass":"your-password"}' \
'https://my-site.ddev.site/user/login?_format=json'
curl -b jar https://my-site.ddev.site/session/token

The second command prints the token string to put in the X-CSRF-Token header on writes, sent together with -b jar.

  • Same-origin deployment. Serving the front end and Drupal from one origin (a reverse proxy, a subpath) avoids CORS entirely and lets cookie authentication work without supportsCredentials. This is the simplest secure setup when the architecture allows it.
  • Server-side rendering only. A framework that fetches on the server (Next.js server components, SvelteKit load functions) never triggers CORS, because the request does not originate in a browser. You still authenticate, but you skip the CORS block.
  • Per-origin patterns. Use allowedOriginsPatterns with a regular expression when preview deployments produce many origins under one domain, instead of listing each.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.