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 |
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 ( |
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.
Cookie authentication and the CSRF token
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? ( |
Default on. A confidential client (a server) can keep a secret; a public client (a browser or mobile app) cannot |
Secret ( |
Client secret. Required for confidential clients |
Grant types |
Which of authorization code, client credentials and refresh token this client may use |
Redirect URIs ( |
Exact URIs the authorization code may be sent to; anything else is refused |
Use 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 ( |
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:
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/debugis a diagnostic route for inspecting the current authentication,/oauth/userinforeturns user claims and/oauth/jwkspublishes the public key for OpenID Connect consumers. -
Issued tokens are
oauth2_tokenentities, 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 wrongcode_verifier), and a403with a valid token because the token’s user lacks the Drupal permission. -
A reverse proxy must forward the
Authorizationheader; 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
allowedOriginsPatternsregular expressions for preview domains. -
Bearer tokens are sent in a header, not a cookie, so
supportsCredentialscan stayfalse. Set it totrueonly for cookie-based cross-origin sessions, and never together with a wildcard origin. -
Authorizationmust appear inallowedHeaders, otherwise the preflightOPTIONSrequest 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.