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.
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.Table of Contents
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
adminuser, 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 noAuthorizationheader at all. WordPress behaved as if no credential was present.
A deliberately invalid Authorization header — then read which error WordPress sends back.
WordPress TRIED to auth: the header arrived and was parsed. Look at the credential or permission.
Identical to sending NO credential: auth was never attempted. Not a password problem at all.
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:
- On the subsite, the credential reaches PHP (Step 7).
- 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:
| Observation | Explanation |
|---|---|
| Root authenticates | a root-site snippet forces app-passwords available |
| Subsite 401s on a valid cred | no enabler → core disables app-passwords on HTTP |
| Subsite 401s identically on garbage | auth never attempted, so the credential is irrelevant |
| Header is present in PHP | it 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
- 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.
- 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_passwordmeans 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.”
- Probe before you edit shared infrastructure. My most confident hypothesis (header stripping) was wrong, and acting on it meant editing an
.htaccessshared by every site in the network. A thirty-second read-only probe overturned the hypothesis and protected the blast radius. Confidence is not evidence.
- 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.
- 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
200on the multisite root and401 rest_not_logged_inon a subsite — same credential, different result. - The trap:
rest_not_logged_inis 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_truesnippet 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.