The JSON:API Route That 403s With No Reason String

There's a specific kind of unhelpful a 403 can be. Try to set a field you don't have permission for through Drupal's JSON:API and you get a real, actionable message back — something like "The current user is not allowed to POST the selected field (status). The 'administer nodes' permission is required." That's a 403 that tells you exactly what to go fix. What I hit while testing the publish pipeline was the other kind: a bare 403, correct credentials, empty detail field, no clue what was actually being refused.

Ruling out the obvious things first

The request was authenticating as the same service account that had been working minutes earlier, against an endpoint that hadn't changed, with a body that hadn't changed either. Nothing in the permission model had been touched. My first assumption — that whatever composer or config change I'd actually been testing at the time had broken something — didn't hold up, because rolling that change back didn't fix the 403 either. That's usually the tell that the real cause is somewhere other than the thing you were just working on.

The module doing this wasn't the one I expected

Drupal has flood protection on login attempts, and I'd assumed that was the relevant mechanism — check the failed-login count, clear it, move on. But the request wasn't going through Drupal's normal login form at all; it was authenticating via HTTP Basic Auth on every request, and the basic_auth module maintains its own, separate flood tracking specifically for that path, keyed by {uid}-{ip} under the event name basic_auth.failed_login_user. It doesn't share state with the login-form flood control most people think of first, and it doesn't need many attempts to trip: a handful of deliberately wrong-password requests made earlier, while testing what happens on bad credentials, was enough to leave an entry in the flood table against that account and IP.

Once that entry exists, basic_auth doesn't just reject the specific bad password that caused it — it stops trusting any credentials from that {uid}-{ip} pair for the flood window, including the correct ones. And it doesn't fail loudly when it does this. The request doesn't get rejected as "wrong password" or "account locked" — it just gets evaluated as if no credentials were supplied at all, falls through to anonymous, and anonymous doesn't have permission to POST an article. What comes back is a permission-shaped 403, from a permission check that's technically correct given what the system thinks it knows, with nothing in the response pointing at the actual cause three layers up.

Finding it

The diagnostic is a direct table query rather than anything in Drupal's admin UI, since flood entries aren't surfaced anywhere you'd normally look:

  • drush sql:query "SELECT * FROM flood WHERE event = 'basic_auth.failed_login_user'"

That returned a row for exactly the account and IP the failing requests were coming from, timestamped right around when the earlier bad-password testing had happened. The fix was equally direct:

  • drush sql:query "DELETE FROM flood WHERE event = 'basic_auth.failed_login_user'"

Deleting it and re-running the exact same request that had just 403'd worked immediately, with no other change made anywhere.

Why the empty detail field is the actual problem

Drupal's own permission-denial responses on JSON:API are, generally, good about this — the field-level 403 quoted above says precisely which permission is missing and why. This one didn't, and the reason is structural rather than a bug in either module: by the time the request reaches the permission check, it genuinely is anonymous, as far as that layer of the stack can tell. The check isn't wrong given its inputs. The information that would explain why it's anonymous — that real credentials were supplied but rejected upstream by a flood gate — never survives to that point in the request. Each layer is behaving correctly in isolation; the failure only makes sense once you know both layers exist and that one can silently override the other's inputs.

That's the actual habit worth keeping from this: a permission-shaped 403 with no explanatory detail, on an endpoint that was working a few minutes ago with unchanged code and unchanged permissions, is worth checking the flood table for before assuming a code or config regression, especially right after any deliberate wrong-credential testing earlier in the same session. The flood table doesn't show up in config:status, doesn't show up in a diff, and won't be touched by rolling back whatever you were actually working on — which is exactly why it's easy to rule out every real change you made and still be stuck.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.