Most guides to enabling REST or JSON:API in Drupal tell you to go to /admin/config/services, tick some checkboxes, and save. That's fine advice for a one-off experiment, and terrible advice for anything you intend to depend on. A permission granted by clicking a checkbox in an admin UI leaves no diff, no code review, no record of who enabled it or why, and no way to reproduce it on staging without either remembering to click the same checkbox there too or exporting config after the fact and hoping you caught everything it touched. If you already run Drupal with a version-controlled config sync directory — which you should — then enabling a REST publishing pipeline is not an admin-UI task. It's a config change, same as adding a field or a content type, and it should go through the same review path as anything else in the repo.
This is the pipeline I built to let scripts — not just browsers — publish articles to this site. It's a handful of config files, a dedicated low-privilege service account, and about ten lines of curl. No contrib modules, no headless-CMS-as-a-service middleman, nothing running outside infrastructure I already control.
JSON:API over REST + RESTUI
Drupal core actually ships two different ways to build a REST interface: the rest module, which requires you to define per-entity-type "REST resources" (traditionally configured through the contrib RESTUI module, since core gives you no admin UI for it at all), and the jsonapi module, which exposes every fieldable entity type automatically, following the JSON:API specification, with no per-resource configuration required.
For anything content-shaped — nodes, media, taxonomy terms — JSON:API is the better default in 2026. It's been stable in core since Drupal 8.7, it needs no contrib dependency, the request/response shape is a documented standard rather than a Drupal-specific convention, and because every entity type is exposed uniformly, there's no per-content-type REST resource config to maintain as your site's content model grows. The rest module still has a place for very specific, hand-shaped endpoints that don't map cleanly onto an entity — but "post an article" maps onto an entity about as cleanly as it's possible to get, so JSON:API it is.
The config, not the checkbox
Three files, all committed to sync/ alongside the rest of this site's configuration:
Enabling the modules
JSON:API needs serialization underneath it, and I want basic_auth available so a script can authenticate without a session cookie. All three are core, so this is purely a core.extension.yml edit, not a composer change:
module: ... basic_auth: 0 ... jsonapi: 0 ... serialization: 0 ...
Turning on writes
This is the part that trips people up. JSON:API ships read-only by default — core/modules/jsonapi/config/install/jsonapi.settings.yml sets read_only: true out of the box, so simply enabling the module gets you a fully functional read API and a silent 403 on every POST until you explicitly opt in to mutation:
read_only: false maintenance_header_retry_seconds: min: 5 max: 10
Committing this as an explicit file in config sync means the decision to allow writes is visible in a diff and in `git blame`, not buried as an unexplained difference between two environments six months from now when someone's trying to figure out why staging behaves differently from production.
A service account role, not an admin one
The single most common shortcut I see in "how to script Drupal content creation" tutorials is authenticating as an existing administrator account. Don't do this. The account a script authenticates as should be able to do exactly the one thing the script needs and nothing else, so that a leaked credential — in a shell history, a misconfigured CI variable, a script accidentally committed — is a contained problem instead of a full site compromise.
id: api_publisher label: 'API Publisher' permissions: - 'access content' - 'create article content' - 'edit own article content' - 'use text format restricted_html'
That's the entire permission set: it can create and edit its own articles, using the same restricted text format already granted to anonymous commenters elsewhere on this site, and nothing else. It cannot touch pages, users, configuration, or anyone else's content. If this credential leaks, the blast radius is "someone can post articles as the bot account," which is annoying and immediately obvious, not "someone has a foothold on the server."
Note the last permission specifically: without some use text format X permission, a role can still create content, but any HTML in the body field gets silently stripped down to Drupal's fallback plain-text format, since formats other than the fallback require explicit permission. It's a two-minute mistake to make and a confusing one to debug, because the API call succeeds and returns 201 — the content just doesn't look like what you sent.
Deploying it
Config files in sync/ are declarations, not actions — they take effect on drush config:import, same as any other config change:
drush config:import -y drush user:create api_bot --mail="api-bot@linkhub.dk" --password="<generated>" drush user:role:add api_publisher api_bot
The user account itself isn't config-managed — user accounts and their passwords don't belong in a git-tracked config export — so it's created imperatively, once, on each environment that needs it, with its own generated password per environment. Staging and production should never share this credential.
Actually posting something
With the module enabled, writes turned on, and a role-scoped account in hand, publishing an article is a single authenticated POST:
curl -X POST https://www.linkhub.dk/jsonapi/node/article \
-u api_bot:'<password>' \
-H 'Content-Type: application/vnd.api+json' \
-H 'Accept: application/vnd.api+json' \
-d '{
"data": {
"type": "node--article",
"attributes": {
"title": "Posted from a script",
"body": {
"value": "<p>Hello from curl.</p>",
"format": "restricted_html"
}
}
}
}'A few things worth calling out about why this works with so little ceremony. First, no CSRF token is needed — Drupal's CSRF protection on unsafe HTTP methods exists specifically to stop a malicious page from riding an authenticated browser's session cookie to make requests on the user's behalf. Basic Auth sends credentials explicitly, on every request, with no ambient cookie to hijack, so the CSRF check doesn't apply to it. That's a deliberate, documented exemption, not an oversight — but it's also exactly why Basic Auth has to stay behind HTTPS without exception, since the credential really is on the wire every time.
Second, the response is a full JSON:API resource object for the created node — including its generated node ID and path alias, if Pathauto is configured for the bundle, which it is here — so a publishing script gets back everything it needs to log or link to what it just created without a follow-up GET.
Basic Auth is the right call here, not a shortcut
The natural objection is that Basic Auth is a dated, unsophisticated mechanism next to a proper OAuth2 flow with token expiry and scoped grants — and for a multi-client, multi-tenant, third-party-integration scenario, that objection is correct, and something like the contrib simple_oauth module is the right upgrade. For a single self-hosted site with a single internal publishing script that I wrote and run, the operational complexity of a token issuance and refresh flow buys close to nothing: there's no third party to scope access away from, no token to rotate on a schedule because there's no separate client population to revoke independently, and one more moving part is one more thing that can silently break during a Drupal core update.
The actual security property that matters — a leaked credential having a small, contained blast radius — comes from the role's permission scope, not from the authentication mechanism's sophistication. Basic Auth behind TLS, against an account that can only create its own articles, gets you most of what a properly scoped OAuth token would, for a fraction of the moving parts. If this ever needs to serve multiple independent scripts or third parties, that's the point at which the added complexity of OAuth2 starts paying for itself, and not a moment before.
What this actually unlocks
The point was never "look, curl can talk to Drupal." It's that the site's publishing surface is no longer bound to a browser and a WYSIWYG field. A small script that pulls from an RSS feed I trust, converts a local Markdown file, or reformats notes from somewhere else entirely can publish here directly, using infrastructure I already run, authenticated against a credential I fully control, without routing through a third-party automation platform's webhook queue in between. That's the same "digital sovereignty" argument that runs through most of what's on this site — the difference between depending on a service you don't control to move your own words from one place you own to another, and just... not needing it to.
The next logical piece, if I build it, is a small conversion script — Markdown in, a JSON:API POST out — so a draft becomes a published article without a manual copy-paste into Drupal's editor at all. That's a small enough project it might just be the next thing that shows up here.