Skip to content

Self-hosted login fails on plain-permalink sites: the ?rest_route= API root is path-extended into non-routing URLs #1366

Description

@dekmeister

Summary

WordPressLoginClient cannot complete the self-hosted login / site-details fetch when the target site uses plain permalinks (permalink_structure empty). Discovery succeeds and an Application Password is issued, but every subsequent REST request resolves to the API index (or 404s) instead of the requested endpoint, so site details never load. In the WordPress iOS app this surfaces as "Cannot load the WordPress site details" (wordpress-mobile/WordPress-iOS, SelfHostedSiteAuthenticator.swift).

Root cause

On a plain-permalink site, WordPress core advertises the REST API root — in the Link; rel="https://api.w.org/" header and in every _links href — as the query-parameter form, not a path:

Link: https://example.com/index.php?rest_route=/; rel="https://api.w.org/"

This is core-default behavior (get_rest_url(), the non-pretty branch deliberately builds …/index.php?rest_route=) and the documented universal form.

WordPressLoginClient takes that href as the API root (wp_api/src/login/url_discovery.rs, API_ROOT_LINK_HEADER = "https://api.w.org/") and then builds endpoint URLs by path-extending it:

  • wp_api/src/request/endpoint.rs → WpOrgSiteApiUrlResolver::resolve calls
  • wp_api/src/parsed_url.rs → by_extending_and_splitting_by_forward_slash, which is url.extend(segments) — itappends to the path and leaves the ?rest_route=/ query untouched.

So a request for /wp/v2/users/me becomes:

https://example.com/index.php/wp/v2/users/me?rest_route=/

WordPress ignores the path segments and honors rest_route=/, returning the API index for every endpoint (or, on servers without a front-controller catch-all, a hard 404). The client parses that root document as the site-details response and fails. The hard-coded /wp-json fallback never fires because discovery itself "succeeded." A repo-wide grep for rest_route finds no handling in the self-hosted discovery/login path (only the WP.com endpoint).

Reproduction

Any self-hosted site with Settings → Permalinks = Plain:

# 1. Advertised API root is the query form:
curl -sI https://SITE/ | grep -i '^link:'
#   link: <https://SITE/index.php?rest_route=/>; rel="https://api.w.org/"

# 2. The exact URL the client builds collapses to the API index:
curl -s "https://SITE/index.php/wp/v2/users/me?rest_route=/" | head -c 120
#   {"name":"…","description":"…","url":"…", …}   ← API ROOT, not the user resource

(wp_rs_cli also can't express this root: --api-root is required to end with /wp-json.)

Expected

The client should support the ?rest_route= API-root form. Either:

  1. When the discovered root carries a rest_route query param, build endpoints by appending to that query value (…/index.php?rest_route=/wp/v2/users/me) rather than the path; or
  2. On discovering the query-form root, probe /wp-json/ and prefer it only if it routes — otherwise use the rest_route query form consistently.

Environment

Workaround (server-side — and a clean controlled confirmation)

Making WordPress advertise the path form fixes it without touching the client, which isolates the client-side path-extension as the defect:

  • .htaccess: RewriteRule ^wp-json/(.*)$ index.php?rest_route=/$1 [QSA,L]
  • mu-plugin: add_filter('rest_url', fn($u) => str_replace('/index.php?rest_route=/', '/wp-json/', $u));
  • (or enable pretty permalinks)

General Note
I am not a PHP developer or familiar with the low level WordPress code. The above was determined by debugging my system with Claude Code after updating to WordPress 7.0 for my WordPress instance.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions