Custom Drupal API key authentication
API access lets a Drupal site exchange data with an ERP, a headless front end, a warehouse system or a partner portal. For an Australian retailer, the path might run from a Melbourne store to a Drupal catalogue while a Brisbane team updates fulfilment rules and a Sydney agency monitors the feed. The integration is useful only when Drupal can identify the caller reliably and apply sensible permissions to every request.
An API key is a bearer secret: possession is enough to impersonate the associated account. That makes hashing, rotation and revocation part of the feature rather than clean-up work for later. If the endpoint handles customer information, its purpose, retention and access controls should also fit the Privacy Act 1988 and the Australian Privacy Principles. Authentication establishes identity; it does not decide which content the caller may read or change.
Choosing an access method
Choose the credential model from the trust boundary, not from whichever header is quickest to implement. A private integration between systems you control can use a focused API key, while delegated access from third parties usually belongs in OAuth 2. Browser sessions and HTTP Basic authentication solve different problems and should not be presented as equivalent replacements.
| Method | Request credential | Best fit | Main caution |
|---|---|---|---|
| API key | X-API-Key header |
Private server-to-server integration | Requires deliberate rotation and revocation |
| HTTP Basic | Base64 username and password | Simple legacy integration | Credentials are easily replayed if exposed |
| OAuth 2 | Short-lived bearer token | Delegated or third-party access | More complex token lifecycle |
| Drupal session | Browser cookie and CSRF token | Authenticated content editors | Not suitable for unattended machine requests |
For a server-to-server integration, a custom provider is a good fit when the client needs one opaque credential, you want simple revocation and no user interaction is required. Bind each key to a dedicated Drupal user, then let normal Drupal permissions control access. This keeps credential validation in one service and avoids creating an administrative account for every partner.
The distinction matters in the Australian market too. An .com.au storefront may call a fulfilment service while a regional campaign team imports landing-page content. A brief that says the feed must be ready “this arvo” still needs an exact UTC deadline, an accountable owner and a documented failure path. Plain language is helpful; ambiguous automation requirements are not.
Preparing the custom module
Create a small custom module named api_access. Keep the provider independent from controllers so the same validation can protect a custom JSON endpoint or an approved JSON:API resource route. JSON:API write requests also require checking the CSRF requirements for the Drupal version in use.
# api_access.info.yml
name: API Access
type: module
description: Authenticates selected Drupal routes with API keys.
core_version_requirement: ^10 || ^11
Use configuration to make the example easy to inspect. A record contains the hash of a key and the Drupal user ID to load; the raw key never belongs in configuration, code or a log. This version avoids password-hashing delays because a generated key has hundreds of bits of entropy rather than being a memorable password.
# config/install/api_access.schema.yml
api_access.keys:
type: config_object
label: API key records
mapping:
records:
type: sequence
label: API key records
sequence:
type: mapping
mapping:
hash:
type: string
label: Key hash
uid:
type: integer
label: User ID
# config/install/api_access.keys.yml
records: []
For a busy service, replace the configuration list with a dedicated key entity or database table that records a key identifier, owner, creation time, expiry and last-used time. Keep the provider’s lookup contract unchanged. Configuration is convenient for a handful of keys, but frequent rotation and useful audit reporting deserve a proper storage model.
Implementing the provider
The provider implements Drupal’s AuthenticationProviderInterface. Its applies() method notices the X-API-Key header, while authenticate() validates it and returns the matching active Drupal account. Returning NULL leaves the request anonymous, so the route’s permission requirement will reject it.
<?php
namespace Drupal\api_access\Authentication;
use Drupal\Core\Authentication\AuthenticationProviderInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\HttpFoundation\Request;
final class ApiKeyAuthenticationProvider implements AuthenticationProviderInterface {
public function __construct(
protected ConfigFactoryInterface $configFactory,
protected EntityTypeManagerInterface $entityTypeManager,
) {
}
public function applies(Request $request) {
return $request->headers->has('X-API-Key');
}
public function authenticate(Request $request) {
$apiKey = trim((string) $request->headers->get('X-API-Key'));
if ($apiKey === '' || strlen($apiKey) > 256) {
return NULL;
}
$candidateHash = hash('sha256', $apiKey);
$records = $this->configFactory->get('api_access.keys')->get('records') ?? [];
foreach ($records as $record) {
if (!isset($record['hash'], $record['uid'])) {
continue;
}
if (!hash_equals((string) $record['hash'], $candidateHash)) {
continue;
}
$account = $this->entityTypeManager
->getStorage('user')
->load((int) $record['uid']);
if ($account && $account->isActive()) {
return $account;
}
}
return NULL;
}
}
Hash each presented key and compare it with the stored hash using hash_equals(). The implementation loads the user only after a match, rejects blocked accounts and ignores malformed records. It also caps the header length so an oversized value cannot trigger unnecessary work.
# api_access.services.yml
services:
api_access.authentication_provider.api_key:
class: Drupal\api_access\Authentication\ApiKeyAuthenticationProvider
arguments:
- '@config.factory'
- '@entity_type.manager'
tags:
- { name: authentication_provider, provider: api_key }
Register the service with the authentication_provider tag and name it api_key. Select that provider explicitly on protected routes with _auth. A matching Drupal permission provides the separate authorisation decision: authentication proves who the caller is, while permissions decide what that account may do.
# api_access.routing.yml
api_access.status:
path: '/api/status'
defaults:
_controller: 'Drupal\api_access\Controller\ApiController::status'
methods: [GET]
options:
_auth:
- api_key
requirements:
_permission: 'access api'
Define access api through api_access_permission() and assign it only to the intended service accounts. Avoid granting the API user broad content-administration rights, and do not depend on provider ordering when a route might also accept browser sessions.
Protecting keys and access
Generate keys with a cryptographically secure random source, show the raw value once and store only its SHA-256 hash. A 32-byte random value is ample.
php -r '$key = bin2hex(random_bytes(32)); echo $key, PHP_EOL, hash("sha256", $key), PHP_EOL;'
The first line is the secret given to the client; the second is what enters Drupal configuration. Add the hash and target user ID through a protected deployment process, then clear Drupal’s configuration cache. Transmit the key only over HTTPS and place it in the client’s secret store. Query-string credentials are especially unsafe because they can leak through browser history, analytics and referrer data.
Give every integration its own Drupal account and key so one partner can be revoked without interrupting everyone else. Rotate by adding a replacement, moving the client to it, then removing the old record. Provision different keys for development, staging and production, and redact credentials from proxy logs, error trackers and support screenshots. Record key identifiers and outcomes in audit logs, never the raw key or full hash.
Authentication does not settle editorial trust. If an integration can create or modify articles, route that content through Drupal’s Workbench moderation content workflow checklist instead of bypassing review. Keep creation and publication permissions separate, and do not let a machine credential inherit the site administrator role. For customer data, document breach escalation and retention decisions; ACSC’s Essential Eight provides a useful Australian baseline for access control, patching and logging.
Testing and operating the service
Test positive and negative cases before connecting a client. A valid key should reach the protected route, while an unknown, expired, malformed or revoked key should not. Also test a blocked Drupal account and confirm that the caller still lacks any permission it was never granted.
curl -i -H "X-API-Key: $API_KEY" https://www.example.com.au/api/status
Load the key from a protected environment variable rather than placing it directly in a script or ticket. Automate provider unit tests for header detection, hash matching, malformed configuration and inactive users, then add a functional route test for the final status code. Drupal may return 403 when an anonymous caller reaches a permission-protected route; if the API contract requires 401, implement that response deliberately in an access checker or exception subscriber.
Operational checks need to survive daylight-saving changes across Australia. Store audit timestamps in UTC, display them in AEST or AWST where useful, and alert on repeated authentication failures. A Sydney support team may call an issue resolved at 4 pm while a Perth operator remains on the previous hour, so alerts should use UTC and identify the relevant local time.
- Generate every API key with at least 256 bits of cryptographic randomness.
- Bind each key to one dedicated, least-privileged Drupal account.
- Restrict key-bearing routes with explicit provider and permission settings.
- Rotate credentials regularly and remove revoked records promptly.
- Log key identifiers and authentication outcomes without recording secrets.