How to Secure JSON:API Endpoints in Drupal with OAuth

Dan Chadwick
7 min readFeb 4, 2024

--

Recently on one of my Drupal projects I was assigned the task of securing all our JSON:API endpoints with OAuth. Since JSON:API has been a part of core for 5 years now (since 8.7.x) I went into this assuming there would either be an out-of-the-box way to accomplish this, or some straightforward contributed module. With that in mind, my process was as follows:

  1. Install a vanilla Drupal site
  2. Enable JSON:API to determine what happens out-of-the-box
  3. Evaluate options for further authentication

To get started I created a new Drupal project, added devel to generate some dummy content, and enabled JSON:API. Right off the bat I could tell things were working and my data was being exposed via routes like /jsonapi/node/article.

Taking a look at the settings for JSON:API there was really only the option to specify if the endpoint should be readonly or allow all CRUD operations, but nothing about further authentication.

Based on the security guide for JSON:API found here on Drupal.org we know that JSON:API will at minimum respect entity and field level access, but how can we now add an additional layer on top of this?

My first stop was the JSON:API Extras module. I added this into my codebase, enabled, and was able to see a lot of new configuration options:

I was able to override path names, disable any routes, and control what fields were available…but nothing about authentication methods. So I decided to head over to Google where my initial search was “drupal secure json api with oauth”.

This lead me to the Drupal REST & JSON API Authentication module which looked like exactly what I needed! I added it to my codebase, enabled, and visited the configuration page only to find that the feature I need (OAuth) is behind a $399 paywall.

This was disappointing and meant back to the drawing board. After some more research I found the Consumers module and its suggestion of pairing with Simple OAuth.

The plan is to:

  1. Create a new permission
  2. Create a new role
  3. Assign new permission to the new role
  4. Create a new user with the new role
  5. Create a consumer with a scope tied to the new role
  6. Alter JSON:API routes to include a check for new permission
  7. Verify access to routes is blocked for non-authenticated users
  8. Verify OAuth flow

Step 1: Create a new permission

Create a new custom module with the following in <module_name>.permissions.yml and enable it.

access jsonapi routes:
title: 'Access JSON:API Routes'
description: 'Allows access to JSON:API routes.'

Step 2: Create a new role

Go to /admin/people/roles/add and create a new role, mine is called JSON:API User:

Step 3: Assign new permission to the new role

Go to /admin/people/permissions and filter for your permission which was created in step 1. Assign it to the role you created in Step 2:

Step 4: Create a new user with the new role

Go to /admin/people/create and create a new user, make sure to assign them the role you created in step 2.

Step 5: Create a consumer with a scope tied to the new role

Add both modules to your codebase (you can just require Simple OAuth as it will pull in Consumers) and enable them. Then go to /admin/config/services/consumer and click +ADD CONSUMER. Fill out the necessary fields and then be sure to select the role you created in step 2 in the SCOPES section:

Go to /admin/config/people/simple_oauth and scroll to the bottom. Select “Generate Keys” and enter a path to a directory where the keys can be stored. I chose ../keys.

Step 6: Alter JSON:API routes to include a check for new permission

There is a section in the JSON:API security considerations guide which details how this can be implemented. Essentially it boils down to altering the JSON:API routes with code like this in a route subscriber which can be added to the same custom module which you created your custom permission in step 1.

  /**
* {@inheritdoc}
*/
protected function alterRoutes(RouteCollection $collection) {
foreach ($collection as $route) {
$defaults = $route->getDefaults();
if (!empty($defaults['_is_jsonapi'])) {
$route->setRequirement('_permission', 'access jsonapi routes');
}
}
}

Step 7: Verify access to routes is blocked for non-authenticated users

Now if you attempt to access JSON:API endpoints without the permission you should see:

{“jsonapi”:{“version”:”1.0",”meta”:{“links”:{“self”:{“href”:”http:\/\/jsonapi.org\/format\/1.0\/”}}}},”errors”:[{“title”:”Forbidden”,”status”:”403",”detail”:”The \u0027access jsonapi routes\u0027 permission is required.”,”links”:{“via”:{“href”:”https:\/\/d10-vanilla.lndo.site\/jsonapi\/node\/article”},”info”:{“href”:”http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html#sec10.4.4"}}}]}

Step 8: Verify OAuth flow

To verify that users can still access this data when they have the correct permission, open up Postman and make a couple requests. The first will be a POST request to /oauth/token to generate a Bearer token using the username and password of the JSON:API User created in step 4. The second will be a GET request that uses that token in a request to JSON:API endpoints.

For the initial/oauth/token request you will want to add the following on the body of a POST request in x-www-form-urlencoded:

  • username: username of the user created in step 4
  • password: password of user created in step 4
  • client_id: the client id of your consumer created in step 3
  • grant_type: password

It should look similar to this (except with your values):

Now take this access_token, copy it, and open up a new GET request. Enter a JSON:API endpoint, add the copied token in the Authorization methods and send the request, you should get back data!

At this point you should have JSON:API endpoints behind OAuth!

Bonus

The default response from JSON:API when a user lacks the permission to view the data leaves a little bit to be desired. The fact that it explicitly mentions the permission name,

{“jsonapi”:{“version”:”1.0",”meta”:{“links”:{“self”:{“href”:”http:\/\/jsonapi.org\/format\/1.0\/”}}}},”errors”:[{“title”:”Forbidden”,”status”:”403",”detail”:”The \u0027access jsonapi routes\u0027 permission is required.”,”links”:{“via”:{“href”:”https:\/\/d10-vanilla.lndo.site\/jsonapi\/node\/article”},”info”:{“href”:”http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html#sec10.4.4"}}}]}

seems like it could pose a security risk. If a bad actor knows the name of the permission they could perhaps find a way to override / spoof / grant it to unintended users (theoretical, no evidence to suggest this is possible currently).

Perhaps a better method would be to throw a 404 here instead, which will produce the following message:

{“jsonapi”:{“version”:”1.0",”meta”:{“links”:{“self”:{“href”:”http:\/\/jsonapi.org\/format\/1.0\/”}}}},”errors”:[{“title”:”Not Found”,”status”:”404",”detail”:””,”links”:{“via”:{“href”:”https:\/\/d10-vanilla.lndo.site\/jsonapi\/node\/article”},”info”:{“href”:”http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html#sec10.4.5"}}}]}

In my opinion, this does a better job of obfuscating things. Throwing a 404 instead of a 403 is an added measure as 404s typically represent “no resource found” — when in fact there is a resource here. So if a bad actor were to guess randomly for URLs they might pass over this as not having JSON:API available/configured.

To do this, I added a custom Event Subscriber based on the Symfony Kernel::CONTROLLER event. This event happens after the controller has been resolved (so we know what route this is) but prior to executing the request. Mine looks like this:

<?php

namespace Drupal\json_api_oauth\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\KernelEvents;

/**
* Defines class JsonApiRouteSubscriber.
*
* @package Drupal\json_api_oauth\EventSubscriber
*/
class JsonApiRouteSubscriber implements EventSubscriberInterface {

/**
* {@inheritdoc}
*
* @return array
* The event names to listen for, and the methods that should be executed.
*/
public static function getSubscribedEvents() {
return [
KernelEvents::CONTROLLER => 'onKernelController',
];
}

/**
* {@inheritdoc}
*/
public function onKernelController(ControllerEvent $event) {
$request = $event->getRequest();
$is_jsonapi_route = $request->attributes->get('_is_jsonapi') ?? FALSE;
if (!$is_jsonapi_route) {
return;
}
$has_permission = \Drupal::currentUser()->hasPermission('access jsonapi routes');
if (!$has_permission) {
throw new NotFoundHttpException();
}
}

}

Since JSON:API routes all have the _is_jsonapi attribute added to them we can use that to ensure we are on the correct route. NOTE: This will happen slightly later in the request handling since we are no longer altering the route during its creation but instead acting once it is resolved.

https://www.drupal.org/docs/8/api/render-api/the-drupal-8-render-pipeline

Bonus Bonus

Add this to your services.yml file to create a new base path for JSON:API, as the /jsonapi/* paths are common and easily guessed.

parameters:
jsonapi.base_path: /hidden/random-path/my-api

So now my API requests go to https://d10-vanilla.lndo.site/hidden/random-path/my-api/node/article.

Thats it! Hope this was helpful 😁

Here is the module on Drupal.org: https://www.drupal.org/project/jsonapi_permission_access

--

--