Authentication and OAuth in Drupal

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

Drupal can be called as an API (JSON:API, REST), but every request still has to be tied to a user, because access control in Drupal is always evaluated for an account. Authentication is the step that turns a credential (a session cookie, a username and password, a bearer token) into that account. This page, checked on 2026-10-30 against Drupal 11.4.x and Simple OAuth 6.1.x (PHP 8.3+), covers the three mechanisms you will meet, how they plug into the request pipeline, and the CORS settings a browser front end needs.

The running example is the Meridian Museum’s gallery kiosk, a Next.js application that reads the collection_object content over JSON:API (see Decoupled Drupal with Next.js). The kiosk needs published objects (anonymous access would do), but its back end also reads unpublished curator drafts, which requires a real identity.

Choosing a mechanism

Mechanism Provided by Suited for Main risk

Cookie (session)

Core (default)

Browser pages served by Drupal itself, and scripts running in the same origin

Cross-site request forgery; requires a CSRF token header for unsafe methods

HTTP Basic

Core module basic_auth (optional)

Quick scripts, internal tools, tests

Long-lived password sent on every request; only acceptable over HTTPS

OAuth 2.0 bearer token

Contrib module Simple OAuth (drupal/simple_oauth)

Decoupled front ends, mobile apps, server-to-server integrations

Key management, over-broad scopes, long token lifetimes

Core ships only the first two. OAuth 2.0 is contributed code, not part of Drupal core, and JSON:API itself is "zero configuration" but does not authenticate anybody: it uses whichever authentication providers are active.

How authentication providers work

Authentication providers are services tagged authentication_provider in a *.services.yml file. On each request the authentication manager asks providers in priority order whether they applies to the request (for example, whether an Authorization header is present) and, for the first that does, asks it to authenticate and return an account. If none applies, the request is anonymous. A tag can be global, in which case it is considered on every route; a non-global provider is used only by routes that name it in the _auth requirement.

Two tag lines from the sources show the difference. Core’s Basic authentication registers itself with { name: authentication_provider, provider_id: 'basic_auth', priority: 100 } and no global flag, while Simple OAuth registers { name: authentication_provider, provider_id: oauth2, global: TRUE, priority: 35 }. Cookie authentication (provider_id: cookie) is the default provider for routes that do not say otherwise. A custom route that must accept bearer tokens or Basic credentials declares it explicitly:

# meridian_collection.routing.yml
meridian_collection.sync_status:
  path: '/meridian/sync-status'
  defaults:
    _controller: '\Drupal\meridian_collection\Controller\SyncStatusController::status'
  methods: [GET]
  requirements:
    _permission: 'administer meridian collection settings'
    _auth: ['oauth2', 'cookie']

The same list is used by REST resource configuration (authentication: [oauth2] in a rest.resource.* config object). Remember that authentication only answers "who is this?". The route’s _permission, entity access and field access still decide what that user may do, which is covered in Users, roles and permissions.

When a person logs in at /user/login, Drupal starts a session and sets a cookie. Any later request that carries the cookie is authenticated as that user. For safe methods (GET, HEAD) that is all that is needed. For unsafe methods (POST, PATCH, DELETE) on API routes, Drupal requires a CSRF token in the X-CSRF-Token header, because browsers send cookies automatically on cross-site requests. The token is available from the /session/token path:

curl -s -c cookies.txt -H 'Content-Type: application/json' \
  -d '{"name":"curator","pass":"'"$MERIDIAN_CURATOR_PASSWORD"'"}' \
  'https://meridian.ddev.site/user/login?_format=json'

TOKEN=$(curl -s -b cookies.txt https://meridian.ddev.site/session/token)

curl -s -b cookies.txt -X PATCH \
  -H "X-CSRF-Token: $TOKEN" \
  -H 'Content-Type: application/vnd.api+json' \
  -d @on-display.json \
  'https://meridian.ddev.site/jsonapi/collection_object/collection_object/7b2f1b2a-0000-4000-8000-000000000001'

Session cookies are configured under session.storage.options in services.yml. In the shipped defaults, cookie_samesite is Lax, which stops the browser sending the cookie on most cross-site sub-requests. A decoupled front end on another site (not merely another subdomain) therefore cannot depend on the cookie, and a SameSite change to None widens the attack surface; use bearer tokens instead. Logging out through /user/logout is itself CSRF-protected since Drupal 10.3.

HTTP Basic authentication

The core basic_auth module lets a client send Authorization: Basic <base64(user:password)>. Enable it with drush en basic_auth. It has no configuration screen. Because it is not global, it applies only to routes that list basic_auth in _auth (JSON:API and REST resources must be configured accordingly), and the credentials are the user’s real login password. Use it only over HTTPS, for dedicated low-privilege integration users, and never for browser applications, since the password would have to be stored in the client. It is stateless and needs no CSRF token.

curl -s -u "kiosk_reader:$MERIDIAN_KIOSK_PASSWORD" \
  -H 'Accept: application/vnd.api+json' \
  'https://meridian.ddev.site/jsonapi/node/exhibition'

Basic authentication does not protect against brute force by itself. Core’s flood control applies to login attempts, so repeated failures lock the account or IP out for a time, which is a denial-of-service surface worth monitoring.

Simple OAuth 6.x

Simple OAuth implements the OAuth 2.0 Authorization Framework (RFC 6749) on top of the PHP League’s OAuth 2.0 Server library. As of 2026-10-30 the project lists 6.1.3 (requires Drupal 10.3 or 11) and 6.0.9 (Drupal 10.2 or 11) as the stable lines; 5.2 (Drupal 9 or 10) is announced as reaching end of life on 2026-12-09, and the project states the 5.2 to 6.x upgrade path as unstable. Install it per project with Composer:

ddev composer require 'drupal/simple_oauth:^6.1'
ddev drush en simple_oauth consumers jsonapi -y

The module depends on Serialization, Consumers, Options and Path Alias; enabling simple_oauth pulls in the consumers project that stores client applications.

What 6.0 removed

Simple OAuth 6.0 removed the implicit grant and the password credentials grant, to follow the OAuth 2.0 specification and current security best practice (see the change record Simple OAuth 6.0.x removes unrecommended grant types). Older tutorials, and the "decoupled Drupal" chapters of books written for 8.x and 9.x, use the password grant to log a user in from a single-page application. That recipe no longer works on 6.x. A site that still needs it can use a separate simple_oauth_password_grant project, as a comment on the change record points out, but only after weighing the security risks. The supported replacements are the authorization code grant with PKCE for applications acting for a user and client credentials for server-to-server calls. The general reasoning is on the site’s legacy implicit and password grants page.

Grants available

  • Authorization Code (plugin id authorization_code): the user is redirected to Drupal’s /oauth/authorize, signs in and approves, and the application exchanges the returned code at /oauth/token. See authorization code and PKCE.

  • Client Credentials: the application authenticates as itself and acts as the user stored on the consumer. See client credentials.

  • Refresh Token: exchanges a refresh token for a new access token and a new refresh token, described in access and refresh tokens.

Grant types are enabled per consumer and per scope. A request fails if the scope does not allow the grant used.

Keys and settings

Tokens are signed JWTs, so the module needs an RSA key pair. Generate it outside the web root and give the web server read access to the private key only:

mkdir -p ../keys
openssl genrsa -out ../keys/private.key 2048
openssl rsa -in ../keys/private.key -pubout -out ../keys/public.key
chmod 600 ../keys/private.key

Enter the two paths at /admin/config/people/simple_oauth (simple_oauth.settings holds public_key and private_key, plus token_cron_batch_size, the number of expired tokens removed per cron run, and disable_openid_connect). A page /admin/config/people/simple_oauth/generate_key can also generate the pair. The keys are secrets: never commit them, and in environments with a configuration split keep the path settings in per-environment configuration. The upstream library checks the private key’s file permissions, which gives false alarms in some container setups; the project documents turning the check off in settings.php:

// settings.php (containers only; prefer fixing the file mode).
$settings['simple_oauth.key_permissions_check'] = FALSE;

Previous versions stored token lifetimes in the module settings (access_token_expiration, authorization_code_expiration, refresh_token_expiration, use_implicit and remember_clients). In 6.x those keys are marked deprecated in the schema and the values live on the consumer instead, as described next.

Consumers

A consumer is a registered client application, a content entity of the Consumers module. Create one at /admin/config/services/consumer/add. The fields added by Simple OAuth are:

Field Meaning

Is Confidential? (confidential)

Default on. A confidential client (a server) can keep a secret; a public client (a browser or mobile app) cannot

Secret (secret)

Client secret. Required for confidential clients

Grant types

Which of authorization code, client credentials and refresh token this client may use

Redirect URIs (redirect)

Exact URIs the authorization code may be sent to; anything else is refused

Use PKCE? (pkce)

Default off. When off, the module relaxes the League’s requirement of a code challenge for public clients; turn it on for every public client

User (user_id)

The account used by client credentials requests

Access token expiration

Seconds; the default is 300

Refresh token expiration

Seconds; the default is 1209600 (14 days)

Automatic authorization, Remember previous approval

Skip or remember the approval screen (first-party clients only)

Scopes

The scopes the client may request

Create separate consumers for each application and environment. The Meridian kiosk gets two: meridian-kiosk-server (confidential, client credentials, acts as a low-privilege kiosk_reader account) and meridian-curator-spa (public, authorization code with PKCE, a redirect URI of the SPA’s callback). The docs recommend short access token lifetimes; their example is 120 seconds for access tokens and a longer period for refresh tokens, renewed before they expire.

Scopes

Scopes limit what a token may do. Simple OAuth 6.x scopes are configuration entities, managed at /admin/config/people/simple_oauth/oauth2_scope/dynamic. Each has a name (for example collection:read), a description, the grant types it is enabled for, an optional parent (umbrella scopes group other scopes) and a granularity: the scope grants either one Drupal permission or a role. The quick demo grants access content. A token receives scopes the consumer is allowed to request, and Drupal still checks the permissions of the token’s user, so a scope never gives more than the user could do. A common pitfall is attaching privileged roles to a consumer’s default scopes; a request without a scope parameter then gets all of them. Keep client credentials consumers narrow, and see scopes, claims and permissions for the design rules.

Requesting tokens

The token endpoint accepts only POST with a form-encoded body (application/x-www-form-urlencoded), not JSON.

POST /oauth/token HTTP/1.1
Host: meridian.ddev.site
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=CHANGE_ME_CLIENT_UUID&client_secret=CHANGE_ME_SECRET&scope=collection:read

In shell scripts read the identifier and secret from the environment:

curl -s -X POST https://meridian.ddev.site/oauth/token \
  --data-urlencode 'grant_type=client_credentials' \
  --data-urlencode "client_id=$MERIDIAN_OAUTH_CLIENT_ID" \
  --data-urlencode "client_secret=$MERIDIAN_OAUTH_CLIENT_SECRET" \
  --data-urlencode 'scope=collection:read'

The response contains access_token, token_type (Bearer), expires_in and, for grants that allow it, refresh_token. The client then calls the API with the header Authorization: Bearer <token>:

GET /jsonapi/collection_object/collection_object?filter[on_display]=1 HTTP/1.1
Host: meridian.ddev.site
Accept: application/vnd.api+json
Authorization: Bearer CHANGE_ME_ACCESS_TOKEN

Authorization code with PKCE

For the curator single-page application, the flow is:

sequenceDiagram participant App as Curator SPA (public client) participant B as Browser participant D as Drupal /oauth/authorize participant T as Drupal /oauth/token participant API as JSON:API App->>App: create code_verifier, code_challenge = S256(verifier) App->>B: redirect with client_id, redirect_uri, scope, code_challenge B->>D: GET /oauth/authorize D->>B: login and approval screen B->>App: redirect_uri?code=... App->>T: POST code, code_verifier, client_id T->>App: access_token, refresh_token App->>API: Authorization Bearer access_token API->>App: JSON:API response

The application generates a random code_verifier and sends its SHA-256 hash as code_challenge with code_challenge_method=S256. When it exchanges the code, it proves possession of the original verifier, so a stolen code is useless on its own. The consumer must have Use PKCE? enabled; the grant plugin disables the League’s code-challenge requirement for public clients when the field is off. Request:

GET /oauth/authorize?response_type=code&client_id=CHANGE_ME_CLIENT_UUID&redirect_uri=https%3A%2F%2Fcurator.example.test%2Fcallback&scope=collection:read&state=CHANGE_ME_RANDOM&code_challenge=CHANGE_ME_CHALLENGE&code_challenge_method=S256 HTTP/1.1
Host: meridian.ddev.site

After the redirect the exchange is a POST /oauth/token with grant_type=authorization_code, code, redirect_uri, client_id and code_verifier. Always check the returned state before the exchange. Section-level detail is in authorization code and PKCE and the browser-specific cautions are in browser-based apps. The project documentation pages for 6.x give the grant mechanics largely by reference to the League’s documentation, and the PKCE field behaviour above comes from the module source, so confirm it against the release you install.

Debugging and operations

  • /oauth/debug is a diagnostic route for inspecting the current authentication, /oauth/userinfo returns user claims and /oauth/jwks publishes the public key for OpenID Connect consumers.

  • Issued tokens are oauth2_token entities, listed at /admin/config/people/simple_oauth/oauth2_token; deleting one revokes it. Cron removes expired tokens in batches (token_cron_batch_size).

  • Typical failures: invalid_client (wrong secret or the client not marked confidential), invalid_scope (scope missing on the consumer or not enabled for the grant), invalid_grant (expired code, redirect mismatch, or a wrong code_verifier), and a 403 with a valid token because the token’s user lacks the Drupal permission.

  • A reverse proxy must forward the Authorization header; some Apache setups need it passed explicitly to PHP-FPM.

CORS

If the Next.js application runs on another origin than Drupal, the browser enforces CORS (see What is CORS?). Drupal core has supported opt-in CORS since 8.2 through the cors.config parameters in services.yml. It is off by default because it has security consequences. Copy default.services.yml to services.yml in the site directory (editing the default file has no effect), set the values, and rebuild caches:

# sites/default/services.yml
parameters:
  cors.config:
    enabled: true
    allowedHeaders: ['authorization', 'content-type', 'x-csrf-token']
    allowedMethods: ['GET', 'POST', 'PATCH', 'OPTIONS']
    allowedOrigins: ['https://gallery.meridian.example']
    allowedOriginsPatterns: []
    exposedHeaders: false
    maxAge: 600
    supportsCredentials: false

The shipped defaults are enabled: false, empty allowedHeaders and allowedMethods, allowedOrigins: ['*'], exposedHeaders: false, maxAge: false and supportsCredentials: false. Pitfalls:

  • Leaving the origin as the wildcard on a site that also uses cookies or Basic authentication invites cross-site abuse. List the exact origins, or use allowedOriginsPatterns regular expressions for preview domains.

  • Bearer tokens are sent in a header, not a cookie, so supportsCredentials can stay false. Set it to true only for cookie-based cross-origin sessions, and never together with a wildcard origin.

  • Authorization must appear in allowedHeaders, otherwise the preflight OPTIONS request fails.

  • Files served directly by the web server (public files) never reach Drupal, so the CORS headers must be added in Apache or nginx.

  • Enabling CORS has been reported to interfere with admin-form submissions when the site’s own host is not handled; verify the back office after changing it.

  • Configure the same list on a CDN or cache that varies on Origin.

Security checklist

  • Terminate TLS in front of every API; bearer tokens and Basic credentials are replayable.

  • One consumer per application, narrow scopes, short access token lifetimes, refresh tokens only where needed.

  • Never store client secrets or keys in exported configuration: inject them from the environment or a secret store.

  • Do not put tokens in URLs or logs.

  • Restrict the administrative permissions that manage consumers, scopes and tokens to trusted administrators.

  • Keep the broader site hardening in mind: Security hardening.

  • For AI agents that call the Meridian site through the MCP Server module, see MCP Server and AI agents.

Corrections to older material

Older tutorials and books written for Drupal 8.x and 9.x often show the password or implicit grant for decoupled front ends; Simple OAuth 6.0 removed both. They also describe the 5.x settings form for token lifetimes, which in 6.x moved to the consumer. For any new decoupled integration use authorization code with PKCE or client credentials, and do not start new sites on Drupal 10, whose support ends on 2026-12-09.