Skip to content

Authenticate users with OAuth

The full OAuth setup from scratch: Simple OAuth keys, scopes and a consumer in Drupal, druxt-auth in Nuxt, and the traps on both sides.

Before you start: the login flow tutorial uses the quickstart's ready-made OAuth setup. This guide builds that setup from scratch on your own backend. Drupal-side commands run in the Drupal project root, with drush installed (composer require drush/drush); if the backend runs in a container, prefix them with your tool's exec command. New to the Drupal side? Start with Drupal for Nuxt developers.

Druxt authenticates with OAuth 2 Authorization Code + PKCE: Simple OAuth on the Drupal side, druxt-auth (built on @nuxtjs/auth-next) on the Nuxt side. The whole flow:

%% The Authorization Code + PKCE login flow between browser, frontend and Drupal
sequenceDiagram
  participant B as Browser
  participant F as Frontend (druxt-auth)
  participant D as Drupal (Simple OAuth)
  B->>F: Log in
  F->>D: /oauth/authorize + code challenge
  D->>B: Drupal login form
  B->>D: credentials
  D-->>F: redirect to /callback with code
  F->>D: /oauth/token + code verifier
  D-->>F: access + refresh tokens
  Note over B,F: Authorization header set on the shared axios instance

Drupal: install and generate keys

Simple OAuth 6.1, the current 6.x line, requires Drupal core 10.3 or later (6.0 accepted 10.2).

composer require drupal/simple_oauth:^6.1
drush pm:enable simple_oauth -y
mkdir ../keys
drush simple-oauth:generate-keys ../keys

The key pair belongs outside the web root; ../keys here sits next to Drupal's web/ directory, not inside it. The directory must exist before the command runs, and the keys never go in git. If a key file must live under a served path for platform reasons, commit only a web-server deny rule for the directory (an .htaccess stub on Apache; an equivalent server rule on nginx). If key generation fails on Windows, generate under WSL2; native OpenSSL setups there have repeatedly produced broken keys that surface later as opaque frontend errors.

Set the token lifetimes on the settings form at /admin/config/people/simple_oauth: short access tokens with longer refresh tokens (minutes and hours respectively) limit the damage of a leaked token while keeping sessions usable.

Drupal: create a scope

Simple OAuth 6 refuses any authorization request it cannot resolve a scope for, and a fresh site has an empty scope list, so every login fails with "Check the scope parameter" until one exists. Create one at /admin/config/people/simple_oauth/oauth2_scope/dynamic/add: grant types authorization_code and refresh_token, with the granularity field set to role and mapped to the role your users hold; authenticated is the usual mapping here.

The Add scope form: machine-readable name, description, grant type checkboxes, and the field mapping the scope to a role

Drupal: create the consumer

A Consumer is Drupal's entity for one registered OAuth client, provided by the consumers module that arrives as a Simple OAuth dependency. Create one at /admin/config/services/consumer (an administrator account is needed for all of these forms):

FieldValue
Client IDA stable id your frontend will use; generate a UUID
SecretEmpty. A browser app cannot keep a secret; PKCE replaces it
ConfidentialOff
PKCEOn
ScopesThe scope you created, set as the consumer's default
Redirect URIhttps://your-frontend/callback, one per environment

The Consumers administration screen listing one client with its Client ID, label and per-environment callback redirect URIs

Every frontend origin that logs in needs its redirect URI registered; the origins people forget are previews and http://localhost:3000. Production setups keep one consumer per environment.

Nuxt: configure druxt-auth

Options are flat, and the module goes in modules, not buildModules: it registers server middleware that nuxt start only loads from modules.

export default {
  modules: [['druxt-auth', { clientId: process.env.OAUTH_CLIENT_ID }]],
};

No scope option is needed. When the app sends none, Drupal applies the consumer's default scopes, and the consumer table above made your scope that default. Pass scope: ['<machine-name>'] only when one consumer serves several scopes and this app must pick; the value must equal the scope's machine name in Drupal, or logins fail with the same "Check the scope parameter" error.

The module registers two strategies. drupal-authorization_code is the PKCE flow above and the one to use. drupal-password (server-side password grant) does not work against the backend this page builds: Simple OAuth 6 removed the password grant (its remaining grants are authorization code, client credentials and refresh token), so that strategy only functions against a Simple OAuth 5.x backend. Trigger a login from any component:

this.$auth.loginWith('drupal-authorization_code');

The /callback route is handled for you.

The login page trap

A "Log in" menu link pointing at /user/login fails: Decoupled Router (the Drupal module that resolves paths for the frontend) has no resolver for Drupal's user routes, so the request for that path errors. Either create your own page at pages/user/login.vue that calls loginWith, or point the menu link somewhere the frontend owns. There is no default login page to fall back on.

Which requests send the token

On login, @nuxtjs/auth-next sets the Authorization header on the app's shared axios instance, and Druxt's client uses that same instance by default, so JSON:API requests for entities, menus and routes carry the token automatically. Drupal then applies the user's real permissions to every response. The caveats:

  • The header is global to that instance. Requests your app makes to third-party APIs through the same $axios include the user's token too. Give those a separate axios instance.
  • Configuring druxt.axios in nuxt.config.js makes the DruxtClient create its own instance, and the automatic header no longer reaches it. Either keep the default wiring, or attach the token explicitly with $druxt.addHeaders({ Authorization: this.$auth.strategy.token.get() }).

The userinfo endpoint is proxied through the frontend only when the API proxy is enabled; without it the browser calls Drupal's /oauth/userinfo cross-origin, which needs CORS.

Writes, and the CSRF question

Form submissions through DruxtEntityForm are JSON:API writes. Enable writes (read_only: 0) first. With Bearer token authentication, no CSRF token is needed; Drupal's CSRF protection applies to cookie sessions. A 403 naming X-CSRF-Token means the request authenticated by cookie (typically a session shared with the Drupal domain) rather than by the OAuth header: attach the Bearer token as above, or fetch a CSRF token from Drupal's /session/token and send it in the X-CSRF-Token header alongside cookie auth.

Logging out

this.$auth.logout() ends the frontend session, and nothing more. Two things survive it: data fetched while logged in stays in the DruxtStore until the page reloads, and the issued tokens stay valid on the Drupal side until they expire, because Simple OAuth has no logout or revocation endpoint of its own (#2945273 adds one as a patch).

The pattern production Druxt sites use is a dedicated logout page that revokes, logs out and cleans up, then forces a full page load, which also empties the store:

<!-- pages/user/logout.vue -->
<template>
  <p>
    Logging out.
    <NuxtLink to="#" @click.native="logout()">Click here</NuxtLink>
    if you are not redirected.
  </p>
</template>

<script>
export default {
  mounted() {
    this.logout();
  },

  methods: {
    async logout() {
      // Revoke the tokens server-side first. This endpoint comes from
      // the #2945273 patch (or your own route that revokes the user's
      // tokens); proxy it through the frontend so the call is
      // same-origin. Skip this step and the tokens outlive the logout.
      await this.$axios.post('/oauth/logout');

      await this.$auth.logout();

      // @nuxtjs/auth-next can leave its cookies behind; clear them so
      // a stale strategy or expiry does not confuse the next login.
      [
        'auth._token.druxt',
        'auth._refresh_token.druxt',
        'auth._token_expiration.druxt',
        'auth.strategy',
      ].forEach((name) => {
        document.cookie = `${name}=; Path=/; Max-Age=0`;
      });

      // A full page load, not router.push: this is what drops
      // privileged content from the DruxtStore.
      location.href = location.origin;
    },
  },
};
</script>

Route the endpoint through the proxy so it shares the frontend origin (alongside druxt.proxy.api, or explicitly):

proxy: {
  '/oauth/logout': process.env.BASE_URL,
},

Known limitations

The limitations documented in the authentication tutorial apply to this setup identically: druxt-auth's authorization-code flow does not refresh tokens automatically (sessions end when the access token expires), and no logout control is included by default. Both are druxt-auth issues rather than backend configuration, so nothing on this page works around them.

Where to go next