Guide

Drupal REST/JSON:API write operations from unexpected sources — detecting compromised API keys

A new node appears on the homepage you did not author. A user account you do not recognise shows up in admin/people with the role administrator.

1. Problem

A new node appears on the homepage you did not author. A user account you do not recognise shows up in admin/people with the role administrator. A field on a published article changes overnight — body intact, canonical URL now points somewhere else. Your editorial team is in Slack asking who pushed the change. Nobody did. Your CI did not run. Your CMS frontend has no scheduled jobs at that hour.

You search "drupal REST API unauthorized POST content created" because that is the shape: an HTTP 200 against a write endpoint, a content mutation in the database, no human admin session at the timestamp. The Drupal Recent log messages page (/admin/reports/dblog) shows nothing dramatic — a few User created and Content created entries, all logged under api_consumer or whatever service account authenticated. The log is not lying. The credential is doing exactly what it is permitted to do. The problem is that the credential is no longer in your control.

This usually surfaces as a drupal_api_writes_total spike with status_code="200" from an IP that has no business writing to your API. It is the digital equivalent of a key being copied — every door it opens gets logged, but the log does not tell you that the keyholder changed.

2. Impact

A compromised API key with write permissions is the most expensive class of Drupal incident, because the damage is durable and silent.

  • Content integrity destroyed. PATCH /jsonapi/node/article/{uuid} silently rewrites the body, byline, or canonical URL of any published article. Search engines index it. Drupal does not version every field by default — restoring requires per-revision audit and manual rollback.
  • Admin accounts created. POST /jsonapi/user/user with roles: ["administrator"] gives the attacker a permanent admin login independent of the API key. Rotating the key does not remove the account.
  • Content silently deleted. DELETE /jsonapi/node/article/{uuid} returns 204. No email, no banner. A 200-article archive can be walked and emptied over a weekend.
  • SEO-grade injection. PATCH published articles to inject affiliate links or redirect text. By the time editorial notices, the change has been crawled and ranked.
  • Supply-chain compromise of downstream consumers. If your Drupal is a headless backend for a Next.js or mobile app, a malicious PATCH on a featured-content list propagates to every device pulling from that endpoint.

A read-side scrape costs you data. A write-side compromise costs you data integrity, user trust, search ranking, and incident-response hours measured in days. The fix is not "rotate the key" — it is "audit every write that key made since it was issued."

3. Why It’s Hard to Spot

A compromised API key looks identical to a legitimate one. That is the entire point of an API key.

  • Drupal logs the request as authenticated. Watchdog records User created node 4187 under the service account's UID. The dblog UI shows your own integration doing its job — no 403, no flag.
  • No per-key request log. Drupal can tell you that a key authenticated, but not which IPs used it, when, against which endpoints, with what method. That information lives only in the web server access log.
  • The CMS UI does not aggregate. A burst of 47 PATCHes from one IP at 03:14 appears in admin/reports/dblog as 47 individual chronological lines. You cannot see the shape.
  • 200-vs-403 is invisible to standard monitoring. Uptime checks, APM, and WAF dashboards alert on errors. A compromised key produces successful writes. Nothing alerts.
  • CI and webhooks pollute the baseline. If your deploy pipeline POSTs to /jsonapi/node/article, your normal traffic already shows API writes from non-admin sessions. An attacker hiding inside that baseline is invisible unless you split by source IP.
  • Attackers throttle. Smart attackers make 3–5 PATCHes per hour. That falls below most rate limits and stays under any anomaly detector unaware of the per-key baseline.

The combined effect: writes happen, are logged correctly, return 200, and look exactly like your own app — until you ask "which IPs are doing the writing, and is that the IP I expect?"

4. Cause

Logystera's Drupal module emits an api.access signal for every request to a REST or JSON:API route. The processor matches it against the drupal_api_writes_total metric, which has the following condition shape:

conditions:
  - event_type == api.access
  - method in [POST, PUT, PATCH, DELETE]
labels:
  method
  status_code

Every non-read API request increments drupal_api_writes_total with the HTTP method and the response status code as labels. Reads live on a separate metric (drupal_api_access_total) so the write series stays dense and low-cardinality.

The status_code label is the diagnostic split. drupal_api_writes_total{status_code="200"} is successful writes — the security-relevant subset, because they mutated state. drupal_api_writes_total{status_code="403"} is rejected attempts — still interesting (probing) but not damage. A steady stream of 403s with zero 200s is reconnaissance; a steady stream of 200s from an unfamiliar source is an active compromise.

The companion metric drupal_api_by_endpoint adds the endpoint dimension — /jsonapi/node/article is content, /jsonapi/user/user is identity, /jsonapi/file/file is asset upload, each with a different blast radius. The DSL definition explicitly recommends correlating against drupal_login_success_total: legitimate writes ride on top of authenticated admin sessions, and a write spike with no corresponding login spike is the loudest possible "this is not your app" signal.

5. Solution

5.1 Diagnose

Three log sources answer "what wrote, from where, and was it successful." Walk them in order.

1. Web server access log — writes grouped by source IP.

grep -E '"(POST|PATCH|PUT|DELETE) /(jsonapi|api)/' /var/log/nginx/access.log \
  | awk '{print $1, $6, $7, $9}' | sort | uniq -c | sort -rn | head -30

Each line corresponds to one or more drupal_api_writes_total events: source IP, method, path, status. A healthy site shows your CI runner, your headless frontend, maybe a partner or two. Any unfamiliar IP with non-zero PATCH/POST/DELETE counts is a candidate.

2. Cross-reference write-time against admin login activity.

The most discriminating step. A write that has no nearby admin login is the suspicious one. Pick a window — 03:00–04:00 UTC last night when editorial was asleep and CI was idle — and pull both signals:

# Writes in the suspect window
grep -E ' (03|04):[0-9]+:[0-9]+ ' /var/log/nginx/access.log \
  | grep -E '"(POST|PATCH|DELETE) /(jsonapi|api)/' \
  | awk '{print $1, $4, $6, $7, $9}'

# Drupal login events in the same window
drush sql:query "SELECT FROM_UNIXTIME(timestamp), hostname, message \
  FROM watchdog \
  WHERE type='user' AND message LIKE 'Session opened%' \
  AND timestamp BETWEEN UNIX_TIMESTAMP('2026-04-26 03:00:00') \
                    AND UNIX_TIMESTAMP('2026-04-26 04:00:00');"

This is the time-correlation step. If the access log shows 14 PATCHes between 03:14 and 03:31 and watchdog shows zero admin sessions opened in that window, the writes were performed by a credential acting on its own — a drupal_api_writes_total spike with no matching drupal_login_success_total. That is the fingerprint of a compromised key.

3. Break the write spike down by endpoint and status code.

grep -E '"(POST|PATCH|DELETE) /(jsonapi|api)/' /var/log/nginx/access.log \
  | awk '{print $7, $9}' | sed 's/?.*//' \
  | sort | uniq -c | sort -rn | head -20

This is the same view drupal_api_writes_total gives you grouped by endpoint and status_code. Shapes to recognise:

  • Many 200s on /jsonapi/node/{bundle} — content mutation by an authenticated key. If the IP is unexpected, active compromise.
  • Many 403s on /jsonapi/user/user — attacker probing with a key that lacks user-management permission. Smaller blast radius, same leak.
  • Mixed 200s and 403s on the same endpoint — attacker exploring what their stolen key can do, settling on what works.
  • A wave of DELETEs returning 204204 No Content is the JSON:API success code for deletion. A burst is a destruction event.

The 200-vs-403 split is the most diagnostic angle. A compromised consumer key with write permissions returns 200 — the system is working as designed, and that is the problem. A leaked low-privilege key returns 403 on most attempts. Either way, the IP is wrong; only the failure rate differs.

5.2 Root Causes

Compromised write access usually traces to one of four origins. Each maps to a distinct signal pattern.

  • Leaked API key in a public repository. A developer commits .env.production or hardcodes a token in a public GitLab/GitHub repo. Scrapers find it within hours. Signal: drupal_api_writes_total{status_code="200"} from a single new IP (often AWS/DigitalOcean) with no drupal_login_success_total. Frequently appears in drupal_top_attack_ips shortly after.
  • Compromised CI/CD secret. Runner credentials leak via a malicious action in the workflow chain, or a developer's machine is compromised. Signal: writes from an IP adjacent to your CI range — same provider, similar subnet — hitting CI's normal endpoints plus ones CI never hits (e.g. /jsonapi/user/user). The hardest variant because the writes look authentic.
  • Phished admin session token. An admin clicks a malicious link, the session cookie is exfiltrated, the attacker drives the API. Signal: drupal_api_writes_total and drupal_login_success_total both spike from a new IP — the login itself was hijacked. Watchdog shows an unfamiliar hostname; drupal_top_attack_ips lights up with the same IP.
  • Stale partner credentials never rotated after staff turnover. Vendor employee leaves, their laptop with the API key is not reclaimed, the key keeps working. Signal: writes from an IP that was legitimate months ago, hitting the same endpoints the partner always hit. No 403s — the key has the right permissions; it just shouldn't still be active.

In every case, the entry signal is drupal_api_writes_total with a non-baseline source, and the corroborating signal is the absence or unfamiliarity of drupal_login_success_total from the same IP.

5.3 Fix

Stop the bleeding before you investigate. Order matters.

  1. Block the source IP at the edge (Cloudflare/nginx/ALB). Stops further writes within 60 seconds. Do not wait to identify the credential.
  2. Disable the API user account. drush user:block invalidates every key, session, and OAuth token tied to it. If you do not know which account was used, block all non-essential service accounts and re-enable one at a time after rotation.
  3. Rotate every API key, OAuth client secret, and session token issued in the last 90 days. All of them. Key reuse is more common than anyone admits.
  4. Audit every write the compromised key made. Query node_revision, user__roles, file_managed, and any custom entity table for changes within the compromise window, by UID of the service account. Roll back individually — there is no batch-undo for arbitrary API writes.
  5. Lock down JSON:API write surface. Install jsonapi_extras, set non-essential resources to read-only, deny unused methods. If your frontend only reads, JSON:API should be GET-only at the module level, not just at permissions.
  6. Per-key source-IP allowlist. Use restrict_by_ip or a reverse-proxy rule. A leaked key then returns 403 from the wrong vantage, surfacing as drupal_api_writes_total{status_code="403"} instead of a successful write.
  7. 2FA on accounts that issue API keys. Prevents the issuance pattern when the admin who issues keys is phished.

5.4 Verify

The signal that should stop is drupal_api_writes_total{status_code="200"} from the blocked source IP. Within minutes of the block, that series should fall to zero for that IP and stay there.

The baseline matters: a busy headless Drupal site with active editors and CI publishing typically runs 5–40 successful writes per minute during working hours and 0–2/min overnight, from a known small set of source IPs (CI runner, headless frontend, one or two partners). "Fixed" is not "zero writes" — it is "writes only from the expected IPs, at the expected hourly rhythm, with drupal_login_success_total rising during admin work hours."

A concrete check at 30 and 120 minutes after the fix:

grep -E '"(POST|PATCH|DELETE) /(jsonapi|api)/' /var/log/nginx/access.log \
  | grep "$(date -u +%d/%b/%Y:%H)" \
  | awk '{print $1}' | sort | uniq -c | sort -rn

Every IP should be one you expect. If after 30 minutes the offender IP shows zero requests, and after 120 minutes the only IPs writing are known integrations, the immediate compromise is contained. If you see drupal_api_writes_total{status_code="403"} from new IPs, the attacker is probing with the same leaked key from a new vantage — you blocked the IP, not the credential. Rotate harder.

Watch drupal_top_attack_ips for 24 hours. Sophisticated attackers rotate IPs and try again. If the same key still works from a new source, your rotation missed something.

6. How to Catch This Early

Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.

This issue surfaces as drupal_api_writes_total. Everything you just did manually — grep the access log, group by IP, cross-reference against admin logins, split by status code — Logystera does automatically. The same drupal_api_writes_total signal is detected, charted by method and status_code, broken down by endpoint via drupal_api_by_endpoint, and correlated with drupal_login_success_total and drupal_top_attack_ips in real time.

1. The signal in the dashboard.

!Logystera dashboard — drupal_api_writes_total over time

drupal_api_writes_total spike at 03:14 UTC, status_code=200 from a previously-unseen source IP, during off-hours with no matching drupal_login_success_total.

2. The alert that fires.

!Logystera alert — Drupal API write surge from unknown source

Critical alert fires within 60s of write-rate deviation: timestamp, source IP, affected endpoint, response status, and absence of corresponding admin login activity.

3. Why this matters.

The fix is simple once you know the problem. The hard part is detecting that someone else is using your credentials before they have finished. A successful API write looks exactly like your own application; the only thing that distinguishes a compromised key from a healthy integration is the shape of the write traffic — its source, timing, endpoint mix, and absence of correlating logins. Logystera turns that shape from a thing you assemble from three log files in a 4 AM panic into a 60-second notification with the IP, endpoint, method, and status code that proves the compromise.

7. Related Silent Failures

If drupal_api_writes_total anomalies are on your radar, these neighbours usually are too:

  • Drupal REST/JSON:API enumerationdrupal_api_access_total 200s walking page[offset]. Read-side exfiltration; often the precursor to the credential leak that enables write-side compromise.
  • Drupal admin role escalationdrupal_role_assigned_total showing administrator granted to a non-admin user, often via the same key as the write spike.
  • Drupal user enumeration via login probesdrupal_login_failed_total clusters with rotating usernames from one IP, often the precursor to phishing the admin who issues keys.
  • Drupal content deletion burstsdrupal_content_deleted_total paired with drupal_api_writes_total{method="DELETE"} 204s. Same signal family, destruction variant.
  • OAuth/Simple OAuth token reuse from new IPdrupal_oauth_token_used_total from an IP that has never used that token before. The tokens-side mirror of compromised API keys.

Each of these is a quiet failure with no 5xx, no WAF hit, no admin UI alert. All are visible in drupal_api_writes_total, drupal_api_by_endpoint, drupal_login_success_total, and drupal_top_attack_ips if you are watching.

See what's actually happening in your Drupal system

Connect your site. Logystera starts monitoring within minutes.

Logystera Logystera
Monitoring for WordPress and Drupal sites. Install a plugin or module to catch silent failures — cron stalls, failed emails, login attacks, PHP errors — before users report them.
Company
Copyright © 2026 Logystera. All rights reserved.