The 401 That Wasn’t About the Password: Debugging WordPress REST Authentication on a Multisite Subsite

Date:

A step-by-step debugging story. The bug took an hour to corner and one line to fix — and the most important move was the diagnostic I ran right before I almost “fixed” the wrong thing.

The short answer: a WordPress REST API call returned 401 rest_not_logged_in on one multisite subsite while the identical credential returned 200 on the root site. It looked like a bad credential, then a site-membership problem, then Apache stripping the Authorization header — and it was none of them. The real cause: WordPress core disables Application Passwords on non-HTTPS connections, and the small filter that force-enables them over HTTP (wp_is_application_passwords_available → __return_true) lived in a per-site snippet that existed on the root but not on the subsite. The fix was one line — add that snippet to the subsite. The lesson that outlasts the bug: when the same credential passes in one place and fails in another, the credential is not the variable — and before you edit shared infrastructure on a hunch, prove the hunch with a probe.

The symptom: a REST API 401 on one subsite, not the root

A small script syncs records into WooCommerce products on a local WordPress multisite. It talks to the WordPress REST API using HTTP Basic auth with an Application Password. It had worked for weeks. Then a push to one subsite started failing:

$ curl -s -H "Authorization: Basic $B64" \
    http://example.test/store/wp-json/wp/v2/users/me
{"code":"rest_not_logged_in","message":"You are not currently logged in.","data":{"status":401}}Code language: JavaScript (javascript)

rest_not_logged_in means WordPress decided the request had no authenticated user at all — not a wrong user, not a permissions failure. As if no credential had been sent.

The environment:

  • A WordPress multisite (subdirectory install) on a local Apache + PHP stack, served over plain HTTP at example.test.
  • Blog 1 = example.test (root). Blog 2 = example.test/store (the subsite we were pushing to).
  • The credential is an Application Password for the admin user, base64-encoded for HTTP Basic auth.

—

Step 1 — “It’s probably the credential”

The obvious first guess. App passwords get revoked, copied wrong, or go stale. We had two credential files floating around, so we tried both. One was clearly stale. But here’s the first real datum:

cred -> http://example.test/wp-json/wp/v2/users/me        : 200   # root: works
cred -> http://example.test/store/wp-json/wp/v2/users/me  : 401   # subsite: failsCode language: JavaScript (javascript)

The same credential authenticated on the root site (200) and failed on the subsite (401). A stale or malformed password can’t pass and fail at the same time. So the credential is valid — the variable that matters is which site we hit. Note this and move on.

Before choosing a fix, decide whose fault it is — yours, the credential’s, or the environment’s.

Step 2 — “Then it’s site membership”

In multisite, a user can exist globally but not be a member of a given subsite. A reasonable theory: admin isn’t a member of the subsite, so it refuses them. We added admin to the subsite.

Still 401. Theory dead. (And it should have been suspicious: users/me only needs you to be authenticated, not to be a member of anything. But it’s a cheap thing to rule out.)

Step 3 — Build the matrix; stop guessing

Two valid admin credentials, two sites, four cells:

cred A -> root      : 200
cred A -> subsite   : 401
cred B -> root      : 200
cred B -> subsite   : 401

This is conclusive: both valid credentials pass on root and fail on the subsite. The credential is definitively not the variable. Whatever is wrong is a property of the subsite, identical across credentials. We’ve now spent two steps proving a negative — but a firmly established negative is worth more than three plausible guesses.

Step 4 — Is it the URL, or the rewrite?

Pretty permalinks rewrite /store/wp-json/... through index.php. Maybe the subsite’s REST route is being handled differently. WordPress offers a rewrite-free form of every REST URL, so compare them:

/store/wp-json/wp/v2/users/me         -> 401 rest_not_logged_in   (pretty)
/store/?rest_route=/wp/v2/users/me     -> 401 rest_not_logged_in   (rewrite-free)
/store/?rest_route=/wp/v2/product      -> 200                       (public read, no auth needed)Code language: JavaScript (javascript)

Two findings. First, the failure is identical in both URL forms, so it isn’t a pretty-permalink quirk. Second — and this matters — a public subsite endpoint returns 200. So the REST API on the subsite is alive and serving; only the authenticated path is broken.

Step 5 — The garbage-credential test

This is the technique that cracks auth bugs. Send a deliberately invalid credential and read which error you get back. The shape of the rejection tells you how far the request got:

root    + garbage auth   -> 401 invalid_username      # WP TRIED to validate, found no user "foo"
subsite + garbage auth   -> 401 rest_not_logged_in    # identical to sending NO credential
subsite + no auth         -> 401 rest_not_logged_inCode language: PHP (php)

Read that carefully:

  • On root, a garbage credential produces invalid_username. WordPress attempted authentication, parsed a username out of the header, and rejected it. The header was received and processed.
  • On the subsite, a garbage credential produces rest_not_logged_in — byte-for-byte the same response as sending no Authorization header at all. WordPress behaved as if no credential was present.
Reading a REST 401: which kind is it?Send a deliberately invalid credential — the shape of the rejection says how far the request got
Send a garbage credential

A deliberately invalid Authorization header — then read which error WordPress sends back.

→ invalid_username

WordPress TRIED to auth: the header arrived and was parsed. Look at the credential or permission.

→ rest_not_logged_in

Identical to sending NO credential: auth was never attempted. Not a password problem at all.

the misleading one
→ probe: header in PHP?

PRESENT → availability gate (HTTPS / environment type). ABSENT → header stripping (Apache / FastCGI).

rest_not_logged_in looks like a credential failure but means the opposite — the credential was never examined.

The natural conclusion: the Authorization header is reaching PHP on root but not on the subsite. Header stripping. Classic Apache-with-FastCGI symptom. I was fairly confident.

The type of 401 tells you how far the request got.

Step 6 — Read the .htaccess, and meet the contradiction

The standard fix for a stripped Authorization header is a one-line rule in .htaccess. So I opened it, expecting it to be missing:

# BEGIN WordPress Multisite
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]   # <-- it's already here
RewriteBase /
...
Code language: Apache (apache)

It was already there. And it matches .* — every request, root and subsite alike. If that rule forwards the header for root, it forwards it for the subsite too; they share one .htaccess and one document root. So the header-stripping theory now had to explain why an unconditional rule would fire for one path and not another. It couldn’t. The evidence and the theory disagreed.

When that happens, the theory is wrong, the evidence is misread, or there’s a hidden variable. Do not “fix” anything yet.

Step 7 — The probe (the most important step)

The tempting move here is to add another header-passthrough line to .htaccess and see if it helps. But that .htaccess is shared by every site on the multisite, including unrelated projects. Editing shared infrastructure on an unproven hunch is how you turn one broken site into several.

So instead of changing anything, I measured. A ten-line throwaway script dropped in the web root, printing only the presence (never the value) of the relevant server variables:

<?php // authprobe.php — throwaway, deleted after use
foreach (['HTTP_AUTHORIZATION','REDIRECT_HTTP_AUTHORIZATION',
          'PHP_AUTH_USER','PHP_AUTH_PW'] as $k) {
    echo "$k: " . (empty($_SERVER[$k]) ? '(unset)' : 'PRESENT(len='.strlen($_SERVER[$k]).')') . "\n";
}
Code language: PHP (php)

Hit through the subsite path, with a valid Authorization header:

SUBSITE  /store/authprobe.php :
  HTTP_AUTHORIZATION:          PRESENT(len=46)
  REDIRECT_HTTP_AUTHORIZATION: PRESENT(len=46)
  PHP_AUTH_USER:               PRESENT(len=5)    # "admin"
  PHP_AUTH_PW:                 PRESENT(len=24)Code language: PHP (php)

Every auth variable is present on the subsite. PHP_AUTH_USER is populated. PHP_AUTH_PW is populated. The header was never stripped. My Step 5 conclusion was wrong, and I’d have wasted a risky shared-file edit chasing it. The probe cost thirty seconds and saved the afternoon.

Confidence is not evidence; a thirty-second probe can overturn your surest hypothesis.

Step 8 — Re-read the evidence with the new fact

Now hold two facts together:

  1. On the subsite, the credential reaches PHP (Step 7).
  2. On the subsite, WordPress acts as if no authentication was attempted (Step 5’s garbage test).

The header is there, but WordPress doesn’t even try to validate it. That points squarely at WordPress core’s gate for Application Passwords:

// wp-includes/user.php (paraphrased)
function wp_is_application_passwords_supported() {
    return is_ssl() || 'local' === wp_get_environment_type();
}
function wp_is_application_passwords_available() {
    return apply_filters( 'wp_is_application_passwords_available',
                          wp_is_application_passwords_supported() );
}
Code language: PHP (php)

The site is plain HTTP (is_ssl() is false) and its environment type is production (not local). So by default, core disables Application Passwords entirely — it returns early without attempting the credential. That produces exactly rest_not_logged_in for any credential, valid or garbage. It fits every observation.

But then why does root work? Because something must be flipping that filter to true on root. The filter is overridable — and overrides can be added by plugins or snippets. Which raises the key question: is that override per-site?

Step 9 — Confirm in the database

This site manages its small PHP customizations through a snippet plugin that stores snippets per blog (one table for blog 1, a numbered table per additional blog). Search both for the filter:

-- blog 1 (root)
SELECT id, name, active FROM wp_snippets
 WHERE code LIKE '%wp_is_application_passwords_available%';
-- -> 1 row, active   (a REST-publisher snippet that force-enables app passwords)

-- blog 2 (subsite)
SELECT id, name, active FROM wp_2_snippets
 WHERE code LIKE '%wp_is_application_passwords_available%';
-- -> (no rows)
Code language: SQL (Structured Query Language) (sql)

And the snippet on blog 1 contains exactly:

add_filter( 'wp_is_application_passwords_available', '__return_true' );Code language: PHP (php)

There it is. The root site force-enables Application Passwords over HTTP; the subsite never did. The snippets are per-site, so the enabler simply didn’t exist on the subsite. Everything is explained:

ObservationExplanation
Root authenticatesa root-site snippet forces app-passwords available
Subsite 401s on a valid credno enabler → core disables app-passwords on HTTP
Subsite 401s identically on garbageauth never attempted, so the credential is irrelevant
Header is present in PHPit was never an Apache/header problem
It “worked before”the subsite enabler had been removed/never re-added after a change

Step 10 — The one-line fix

Add the same enabler to the subsite, as a per-site snippet (never a network-wide mu-plugin — that would change every sibling site’s auth posture):

<?php
/**
 * Enable Application Passwords over plain HTTP — LOCAL ONLY.
 * Core gates app-passwords behind is_ssl() || env==local; this local site is HTTP+production.
 * Retire on any real HTTPS host.
 */
add_filter( 'wp_is_application_passwords_available', '__return_true' );
Code language: PHP (php)

Activate it on the subsite, and:

/store/wp-json/wp/v2/users/me : 200
{"id":1,"name":"admin",...}Code language: JavaScript (javascript)

Authentication restored. The sync pushed its products on the next run.

—

Why it was safe to make HTTPS the deciding factor

A sanity check confirmed the diagnosis from the other direction. The same credential family, pointed at the production HTTPS version of the network, authenticated everywhere with no special snippet:

https://example.com/                 users/me : 200
https://example.com/site-a/           users/me : 200
https://example.com/site-b/           users/me : 200Code language: JavaScript (javascript)

That’s exactly what the theory predicts: on HTTPS, is_ssl() is true, core enables Application Passwords by default, and no per-site enabler is needed. The bug is specific to plain-HTTP local development. If you only ever hit your sites over HTTPS, you will never see it.

—

Lessons

  1. When the same input passes here and fails there, the input is not the variable. Two steps spent proving “it’s not the credential” felt slow, but they redirected the whole investigation away from a dead end. The same trap — chasing the variable you can see instead of the one that actually governs — shows up well beyond auth; it is why a database’s connection limit gets mistaken for its concurrency limit.
  1. The garbage-credential test is the sharpest tool for auth bugs. The type of 401 tells you how far the request got. invalid_username/incorrect_password means auth was attempted (header received). rest_not_logged_in, identical to no-auth, means it wasn’t. That single distinction is what separated “header problem” from “auth-not-attempted problem.”
  1. Probe before you edit shared infrastructure. My most confident hypothesis (header stripping) was wrong, and acting on it meant editing an .htaccess shared by every site in the network. A thirty-second read-only probe overturned the hypothesis and protected the blast radius. Confidence is not evidence.
  1. Know your platform’s defaults. WordPress disabling Application Passwords over non-HTTPS is a deliberate security default, not a bug. Half the investigation was rediscovering a documented gate. The fix is a local-only override — and it should never ship to an HTTPS host.
  1. Per-site config is a per-site liability. The whole incident is “a setting was present on site A and absent on site B.” Anything stored per-site (snippets, options, plugin activation) is a place where two sites silently diverge. When something works on one and not another, diff their per-site config early.

—

Appendix — the diagnostic ladder, in order

# 1. Same credential, two sites — is it the credential?
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Basic $CRED" $ROOT/wp-json/wp/v2/users/me
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Basic $CRED" $SUB/wp-json/wp/v2/users/me

# 2. Rewrite-free URL form — is it the permalink rewrite?
curl -s -H "Authorization: Basic $CRED" "$SUB/?rest_route=/wp/v2/users/me"

# 3. Garbage credential — was authentication even ATTEMPTED?
curl -s -H "Authorization: Basic Zm9vOmJhcg==" $ROOT/wp-json/wp/v2/users/me   # expect invalid_username
curl -s -H "Authorization: Basic Zm9vOmJhcg==" $SUB/wp-json/wp/v2/users/me    # rest_not_logged_in => not attempted

# 4. Webroot probe — does the header actually REACH PHP? (delete after)
#    prints PRESENT/(unset) for HTTP_AUTHORIZATION / PHP_AUTH_USER / PHP_AUTH_PW

# 5. Database — is the per-site enabler present on each blog?
SELECT id,name,active FROM wp_snippets    WHERE code LIKE '%application_passwords_available%';
SELECT id,name,active FROM wp_2_snippets  WHERE code LIKE '%application_passwords_available%';
Code language: Bash (bash)

If the credential passes on root, the header reaches PHP on the subsite, and the garbage test shows auth isn’t attempted — you are looking at an availability gate (HTTPS/environment), not a credential, a permission, or Apache. Check the per-site override.

The short version

  • Symptom: one valid Application Password got 200 on the multisite root and 401 rest_not_logged_in on a subsite — same credential, different result.
  • The trap: rest_not_logged_in is byte-identical to sending no credential, so it reads like a credential or header problem. It isn’t — it means authentication was never even attempted.
  • Root cause: WordPress core disables Application Passwords on non-HTTPS connections; the per-site filter that force-enables them over HTTP existed on the root but not on the subsite.
  • Fix: add the one-line wp_is_application_passwords_available → __return_true snippet to the subsite — local HTTP only, never on a real HTTPS/production host.
  • Method that found it: the garbage-credential test (which kind of 401?) told us auth wasn’t attempted, and a read-only probe proved the header reached PHP — before any shared file was touched.

Frequently Asked Questions

What does rest_not_logged_in mean?

It means WordPress decided the request had no authenticated user at all — not a wrong user and not a permissions failure. The response is byte-for-byte identical to sending no Authorization header, which is the tell: authentication was never attempted. Contrast it with invalid_username or incorrect_password, which mean WordPress did try to authenticate and rejected what it found.

Why does the WordPress REST API return 401 on HTTP but work on HTTPS?

Because WordPress core gates Application Passwords behind is_ssl() || wp_get_environment_type() === 'local'. Over HTTPS is_ssl() is true, so app-password authentication is enabled by default. On a plain HTTP production-type site it is disabled outright, and any credential — valid or not — comes back as rest_not_logged_in.

How do I enable Application Passwords over plain HTTP for local development?

Add the filter add_filter( 'wp_is_application_passwords_available', '__return_true' ); on the site that needs it. Treat it as local-only: it deliberately overrides a security default, so it must never ship to a real HTTPS host. On a multisite, the filter is per-site — add it to each blog that needs it.

Why does the same credential work on the multisite root but not on a subsite?

Because the enabler is per-site configuration. The root site carried the force-enable snippet; the subsite never did. Anything stored per-site — snippets, options, plugin activation — is a place where two sites silently diverge, which is exactly why a credential can pass in one blog and fail in another.

How can I tell whether a 401 is a credential problem or an availability gate?

Run the garbage-credential test: send a deliberately invalid credential and read the error. If you get invalid_username/incorrect_password, authentication was attempted, so the header arrived and the problem is the credential or a permission. If you get rest_not_logged_in — identical to no auth — it was not attempted; then probe whether the header reaches PHP to separate an availability gate (HTTPS/ environment) from header stripping.

Share post:

Subscribe

Popular

More like this
Related

How to Merge Spreadsheets by URL (and Why Our Tool Reported 5 Misses That Weren’t)

Matching two spreadsheets on a URL column is the same job as matching a stock list to a price list by product code. It goes wrong in the same two places: how you clean the code, and how you count what didn't match.

Many-to-One: Why a Mapping Backlog Outgrows Its Target Taxonomy

You're mapping a messy source vocabulary onto a clean target taxonomy. The target has about 5,600 slots, so the job must be roughly 5,600 decisions — right? The backlog is already past 8,900, and still climbing.

The Fix That Didn’t Take: When a Content-Hash Gate Skips Your Code Change

You shipped a fix to how one field is computed, re-ran the pipeline across everything, and it reported a clean run. Then the consistency check still flags those records as wrong. The fix is in the code, the rebuild "ran" — and the records never changed. Your own optimisation skipped them.

Your Dedup Scan Is Over-Counting — Because It Forgot What You Already Fixed

The report said 80 duplicate groups still to resolve. You'd been resolving them for days. You run it again after a big batch and it *still* says 80-ish. The backlog isn't stubborn — the scan is counting work you already finished.