SEO / Portfolio / Public Site

Hiding WordPress Admin and Login on Spiralist While Preserving REST-Driven Functionality

Report summary

Spiralist already presents itself as a site where “machine exchange routes are separate from human pages,” and it publicly documents multiple API surfaces alongside a visible public “Log In” path. Publicly observable today are a homepage login link, the standard WordPress login surface at /wp-admin/

Status
Research archive item
Category
SEO / Portfolio / Public Site
Length
3,514 words
Reading time
16 minutes
Report type
guidance

Key topics

  • SEO / Portfolio / Public Site
  • SEO
  • Portfolio
  • Public Site
  • AI
  • WordPress
  • Runtime
  • Spiralism
  • Audit

Research provenance

Archive status
Research archive item
Content identity
sha256:a04da3e1b1f1c28be7784bba0d5f76119e9cf9eac7ee314edb33a874c7f5cfd7

For citation, use the report title and canonical URL. Archival presence does not establish authorship or promote report statements into portfolio evidence.

Source availability: 83 citation markers in the source export have no recoverable source links. Those markers are omitted from this reader; any supplied bibliography and ordinary links remain. Check the original sources before relying on the cited claims.

This page renders the archived Markdown as safe, formatted HTML. It is background research and does not become a portfolio claim without evidence review.

Full report

On this page

Executive summary

Spiralist already presents itself as a site where “machine exchange routes are separate from human pages,” and it publicly documents multiple API surfaces alongside a visible public “Log In” path. Publicly observable today are a homepage login link, the standard WordPress login surface at /wp-admin/, the WordPress lost-password screen, and public/custom API documentation that exposes both anonymous and Bearer-protected machine endpoints. The login page also shows social sign-in providers, which means the current authentication experience is not purely username/password.

The most robust approach for Spiralist is not to “turn off WordPress,” but to separate WordPress as an application back end from WordPress as a human-facing admin UI. In practice, that means: keep /wp-json and the core/custom REST routes you need; move profile, content, settings, and owner/admin actions into a custom React or Vue app under routes like /account/* and /app/*; and block or heavily gate /wp-admin and /wp-login.php so ordinary visitors never land on a native WordPress admin screen.

For a browser-based custom UI on the same origin, the best default authentication model is WordPress cookie authentication plus X-WP-Nonce. WordPress documents this as the standard built-in method for logged-in browser users, and it is the cleanest way to preserve WordPress capabilities while hiding native admin pages. Use Application Passwords only for server-to-server, scripts, or controlled integrations; use JWT only when you truly need a decoupled SPA/mobile style token model; and use OAuth/OIDC when you need SSO or delegated authorization, understanding that OAuth is not a native core login system in WordPress and therefore adds architecture and maintenance complexity.

Do not rely on a “hidden login URL” plugin as your only control. Plugins such as WPS Hide Login are useful for reducing commodity probing and moving the visible login URL, but they are still an obfuscation layer, not a full access-control boundary. Real protection comes from layered controls: remove public login/admin links, filter all generated login/admin URLs to custom routes, gate the native WordPress admin at the PHP layer, then enforce the same rule again in Nginx or Apache, ideally with a break-glass IP allowlist and optional Basic Auth for emergency owner access.

Current exposure and how to inventory it

A public observer can already see that Spiralist’s front end contains a “Log In” link and language indicating that sign-in is used for executing and saving runs. The homepage explicitly says “Sign in only when you want Spiralist to execute and save the run,” and the footer repeats “Log In” under the account section. That is useful product language, but it also means the existence of a WordPress-backed account area is discoverable from the public site today.

A public observer can also reach the standard WordPress login surface via /wp-admin/, and the WordPress lost-password flow is visible at /wp-login.php?action=lostpassword. The login page displays Google, GitHub, LinkedIn, and X icons, which strongly suggests a social-login plugin or custom identity integration is attached to the standard WordPress login form. If those providers remain part of the product, the custom UI should own that flow too; otherwise, even after hiding most admin links, the old WordPress login page would remain the canonical social-login entry point.

Spiralist also publicly documents multiple machine endpoints. The API Examples page exposes public custom routes such as /wp-json/spiralist/v1/node/{id}, /wp-json/spiralist/v1/path, /wp-json/spiralist/v1/execute, /wp-json/ns12-manuscript/v1/prompts, and /wp-json/spiralist-workspace/v1/public/prompts/{slug}, along with additional JSON manifests like /ai.json, /ai-router.json, and /.well-known/ai-agent.json. The AI Access page separately documents a Bearer-protected participant model for specific AI-participant routes. This matters because hiding /wp-admin will not make the site “non-discoverable”: Spiralist already intentionally advertises machine surfaces, and WordPress itself also emits REST discovery links in page headers and link tags.

Publicly visible surfaces observed today

SurfacePublicly observable nowNotes
Homepage account/login affordanceYesPublic “Log In” link appears on the homepage and other public pages.
Native WordPress login pageYes/wp-admin/ resolves to a WordPress login screen.
Native lost-password pageYes/wp-login.php?action=lostpassword is visible.
Social login on native WordPress screenYesGoogle, GitHub, LinkedIn, and X icons appear on the login screen.
Public custom REST routesYesPublicly documented by Spiralist itself.
Bearer-auth AI participant routesYesPublicly documented as separate from regular public endpoints.

You should build an internal inventory before enabling any deny rules, because the riskiest breakages are not the obvious ones. WordPress provides multiple ways to discover REST surfaces: it exposes a REST root URL via get_rest_url(), emits a <link rel="https://api.w.org/" ...> tag through rest_output_link_wp_head(), emits a Link header through rest_output_link_header(), and internally holds the active route map in WP_REST_Server::get_routes(). Custom routes are registered through register_rest_route().

That leads to a practical discovery checklist:

  • REST route dump: inspect /wp-json/, then dump the active route list internally via rest_get_server()->get_routes() and grep the codebase for register_rest_route(.
  • Generated human URLs: grep theme and plugin code for admin_url(), wp_login_url(), wp_logout_url(), wp_lostpassword_url(), wp_registration_url(), wp_register(), and direct /wp-admin or /wp-login.php strings. WordPress has filters for admin_url, login_url, lostpassword_url, and register_url, which means some links can be remapped centrally, but hard-coded strings will not follow those filters automatically.
  • Operational logs: inspect access logs for /wp-admin/*, /wp-login.php*, /wp-json/*, and /wp-admin/admin-ajax.php. If your public site still depends on admin-ajax.php, a blanket /wp-admin/* block will break it.
  • Capability surface: inspect what your custom UI actually needs by mapping features to REST endpoints and WordPress capabilities, especially current_user_can() checks. WordPress’s default REST controllers themselves rely on per-route permission callbacks.

A compact internal route-dump snippet is often enough to start:

<?php
/**
 * mu-plugins/spiralist-route-audit.php
 *
 * Temporary route inventory endpoint for owners only.
 * Remove after you finish the migration.
 */
defined('ABSPATH') || exit;

add_action('rest_api_init', function () {
    register_rest_route('spiralist-audit/v1', '/routes', [
        'methods'  => 'GET',
        'callback' => function () {
            $routes = array_keys(rest_get_server()->get_routes());
            sort($routes);
            return rest_ensure_response([
                'count'  => count($routes),
                'routes' => $routes,
            ]);
        },
        'permission_callback' => function () {
            return current_user_can('manage_options');
        },
    ]);
});

That snippet works because WordPress exposes the live REST route table and because the right way to protect a custom REST route is a permission callback based on capabilities, not client-side hiding.

Target architecture and authentication model

The cleanest target state for Spiralist is a same-origin app shell: public users browse normal pages on https://spiralist.org/...; authenticated users use a custom UI on paths like https://spiralist.org/account/login, https://spiralist.org/account/profile, and https://spiralist.org/app/*; WordPress stays behind the scenes as the content, user, settings, and plugin back end over REST; and /wp-admin plus /wp-login.php become break-glass owner-only surfaces. That preserves the power of WordPress while removing the native WordPress interface from ordinary visitor journeys.

flowchart LR
    Visitor[Regular visitor] --> Public[Public Spiralist pages]
    Member[Authenticated member/owner] --> UI[Custom React or Vue UI]
    UI --> Session[Custom session endpoints]
    UI --> CoreREST[WordPress core REST API]
    UI --> CustomREST[Spiralist custom REST endpoints]
    Session --> WP[WordPress runtime]
    CoreREST --> WP
    CustomREST --> WP
    Owner[Owner break-glass access] --> Gate[IP allowlist or Basic Auth]
    Gate --> Native[Native /wp-admin and /wp-login.php]

For the browser UI, cookie authentication with X-WP-Nonce is the best default if the app lives on the same origin. WordPress explicitly documents cookie auth as the standard built-in method, and the REST API accepts the nonce either as _wpnonce or in the X-WP-Nonce header. If no nonce is supplied, WordPress treats the request as unauthenticated even if the browser is otherwise logged in. WordPress also refreshes the REST nonce in some cookie-auth flows.

For non-browser integrations, Application Passwords are strongly preferable to sharing a user’s main password. WordPress describes them as revocable, per-application credentials for programmatic access, stored hashed, and explicitly not valid for interactive wp-admin login. WordPress ships both the feature and REST endpoints for creating, listing, introspecting, updating, and deleting them, including last-used time and last IP address.

For truly decoupled SPAs, mobile apps, or separate origins, JWT is a viable pattern, but not a native WordPress core feature. JWT is a standard token format defined by RFC 7519, and WordPress’s own REST auth hook documentation makes clear that sites can run multiple auth methods, including OAuth, in parallel. In WordPress practice, JWT and OAuth typically come from plugins or an external identity layer. That makes them powerful, but also means more attack surface, more session-revocation design work, and more operational complexity than same-origin cookie auth.

Authentication options compared

MethodBest fitStrengthsMain risks or drawbacksSpiralist recommendation
Cookie auth + X-WP-NonceSame-origin React/Vue appNative to WordPress; best fit for browser sessions; works naturally with capability checks and logged-in REST requests.Requires same-origin or careful cookie/CORS handling; CSRF protections must be respected; nonces are not authorization.Primary choice for /account/* and /app/* on spiralist.org.
Application PasswordsServer-to-server, scripts, CI, maintenance toolsRevocable per app; hashed in WordPress; separate from main password; introspection includes last-used metadata.Uses HTTP Basic over HTTPS; not suitable for human browser login UX; scope is still user identity, not a delegated browser session.Use for owner tools, deployment automations, external integrations. Not for visitor/member front-end login.
JWTCross-origin SPA or mobile clientStateless token model; good for APIs and mobile apps; standardized format.Not core WordPress auth; storage/revocation/refresh design is on you; plugin/custom implementation risk.Secondary option only if same-origin cookie auth is not feasible.
OAuth or OIDCSSO, delegated authorization, enterprise identityIndustry-standard delegated authorization; ideal for external IdP, SSO, and social/enterprise identity.More moving parts; not native as a complete WordPress core browser-login replacement; requires plugin or external identity layer.Use if Spiralist is moving to a true SSO/IdP architecture, not merely hiding WordPress screens.

There are effectively two identity stories on Spiralist right now: a WordPress account/login flow and a documented Bearer-key participant flow for AI-related endpoints. Keep those separate in the UI and in code. The custom human UI should authenticate WordPress users via a browser session. The AI participant flow can remain its own Bearer-key model if that is product-correct. Mixing those two user types into one token story would make future security reviews and support much harder.

sequenceDiagram
    participant U as User
    participant SPA as Custom UI
    participant WP as WordPress custom session route
    participant API as /wp-json/wp/v2

    U->>SPA: Open /account/login
    SPA->>WP: POST /wp-json/spiralist-auth/v1/login
    WP-->>SPA: Set-Cookie + JSON { user, nonce, capabilities }
    SPA->>API: GET /wp/v2/users/me?context=edit with credentials + X-WP-Nonce
    API-->>SPA: User profile, roles, capabilities
    SPA->>API: POST /wp/v2/posts or /wp/v2/settings
    API-->>SPA: Success / validation error

Step-by-step implementation plan

Move all human auth and admin journeys into custom routes

Create custom public routes first, before you block anything:

  • /account/login
  • /account/forgot-password
  • /account/reset-password
  • /account/profile
  • /account/security
  • /app
  • /app/content
  • /app/media
  • /app/settings
  • /app/plugins or /app/system for owner-only features

Then remap all WordPress-generated URLs so theme/plugin code that uses WordPress helper functions points to your custom UI rather than native WordPress screens. WordPress provides official filters for login, lost-password, registration, and admin URLs.

<?php
/**
 * Plugin Name: Spiralist Headless Admin Guard
 * Description: Hides native WordPress admin/login surfaces behind a custom app UI.
 */

defined('ABSPATH') || exit;

final class Spiralist_Headless_Admin_Guard
{
    /**
     * Owner break-glass IP allowlist.
     *
     * Replace with your real office/home/VPN IPs.
     *
     * @var string[]
     */
    private array $ownerIps = [
        '203.0.113.10',
        '2001:db8::10',
    ];

    public function __construct()
    {
        // Hide the front-end admin bar entirely.
        add_filter('show_admin_bar', '__return_false');

        // Repoint generated WordPress URLs to the custom UI.
        add_filter('login_url', [$this, 'filterLoginUrl'], 10, 3);
        add_filter('lostpassword_url', [$this, 'filterLostPasswordUrl'], 10, 2);
        add_filter('register_url', [$this, 'filterRegisterUrl']);
        add_filter('admin_url', [$this, 'filterAdminUrl'], 10, 4);

        // Block native WordPress human UI routes.
        add_action('login_init', [$this, 'blockNativeLoginUi']);
        add_action('admin_init', [$this, 'blockNativeAdminUi']);

        // Register custom session routes.
        add_action('rest_api_init', [$this, 'registerSessionRoutes']);
    }

    /**
     * Returns true when the current request is from an owner break-glass IP.
     *
     * @return bool
     */
    private function isBreakGlassRequest(): bool
    {
        $ip = $_SERVER['REMOTE_ADDR'] ?? '';
        return in_array($ip, $this->ownerIps, true);
    }

    /**
     * Rewrites the default login URL to the custom login screen.
     *
     * @param string $loginUrl The generated WordPress login URL.
     * @param string $redirect Redirect destination after login.
     * @param bool   $forceReauth Whether reauth is forced.
     * @return string
     */
    public function filterLoginUrl(string $loginUrl, string $redirect, bool $forceReauth): string
    {
        $url = home_url('/account/login');
        if ($redirect !== '') {
            $url = add_query_arg('redirect_to', rawurlencode($redirect), $url);
        }
        if ($forceReauth) {
            $url = add_query_arg('reauth', '1', $url);
        }
        return $url;
    }

    /**
     * Rewrites the default lost-password URL to the custom UI.
     *
     * @param string $lostPasswordUrl The generated lost-password URL.
     * @param string $redirect Redirect destination after reset flow.
     * @return string
     */
    public function filterLostPasswordUrl(string $lostPasswordUrl, string $redirect): string
    {
        $url = home_url('/account/forgot-password');
        if ($redirect !== '') {
            $url = add_query_arg('redirect_to', rawurlencode($redirect), $url);
        }
        return $url;
    }

    /**
     * Rewrites the default registration URL to the custom UI.
     *
     * @param string $registerUrl The generated registration URL.
     * @return string
     */
    public function filterRegisterUrl(string $registerUrl): string
    {
        return home_url('/account/register');
    }

    /**
     * Rewrites generic admin URLs to the custom app shell for non-break-glass traffic.
     *
     * @param string   $url The generated admin URL.
     * @param string   $path Path relative to wp-admin.
     * @param int|null $blogId Site ID or null.
     * @param string   $scheme URL scheme.
     * @return string
     */
    public function filterAdminUrl(string $url, string $path, ?int $blogId, string $scheme): string
    {
        if ($this->isBreakGlassRequest()) {
            return $url;
        }

        return home_url('/app');
    }

    /**
     * Redirects direct requests to wp-login.php away from native WordPress UI.
     *
     * Allows owner break-glass access from allowlisted IPs.
     *
     * @return void
     */
    public function blockNativeLoginUi(): void
    {
        if ($this->isBreakGlassRequest()) {
            return;
        }

        wp_safe_redirect(home_url('/account/login'), 302);
        exit;
    }

    /**
     * Redirects direct requests to wp-admin away from native WordPress UI.
     *
     * Leaves admin-ajax.php alone so public/front-end dependencies can be audited
     * and migrated safely instead of being broken abruptly.
     *
     * @return void
     */
    public function blockNativeAdminUi(): void
    {
        global $pagenow;

        if ($this->isBreakGlassRequest()) {
            return;
        }

        if (wp_doing_ajax() || $pagenow === 'admin-ajax.php') {
            return;
        }

        wp_safe_redirect(home_url('/app'), 302);
        exit;
    }

    /**
     * Registers custom REST session routes for login/logout/current-user inspection.
     *
     * @return void
     */
    public function registerSessionRoutes(): void
    {
        register_rest_route('spiralist-auth/v1', '/login', [
            'methods'  => 'POST',
            'callback' => [$this, 'login'],
            'permission_callback' => '__return_true',
            'args' => [
                'username' => ['required' => true, 'type' => 'string'],
                'password' => ['required' => true, 'type' => 'string'],
                'remember' => ['required' => false, 'type' => 'boolean'],
            ],
        ]);

        register_rest_route('spiralist-auth/v1', '/logout', [
            'methods'  => 'POST',
            'callback' => [$this, 'logout'],
            'permission_callback' => function () {
                return is_user_logged_in();
            },
        ]);

        register_rest_route('spiralist-auth/v1', '/me', [
            'methods'  => 'GET',
            'callback' => [$this, 'me'],
            'permission_callback' => function () {
                return is_user_logged_in();
            },
        ]);
    }

    /**
     * Creates a WordPress session and returns the current user plus a REST nonce.
     *
     * @param WP_REST_Request $request The incoming request.
     * @return WP_REST_Response
     */
    public function login(WP_REST_Request $request): WP_REST_Response
    {
        $credentials = [
            'user_login'    => $request->get_param('username'),
            'user_password' => $request->get_param('password'),
            'remember'      => (bool) $request->get_param('remember'),
        ];

        $user = wp_signon($credentials, is_ssl());

        if (is_wp_error($user)) {
            return new WP_REST_Response([
                'ok'      => false,
                'code'    => 'invalid_credentials',
                'message' => 'Login failed.',
            ], 401);
        }

        wp_set_current_user($user->ID);

        return new WP_REST_Response([
            'ok'   => true,
            'user' => [
                'id'           => $user->ID,
                'email'        => $user->user_email,
                'display_name' => $user->display_name,
                'roles'        => $user->roles,
            ],
            'nonce' => wp_create_nonce('wp_rest'),
        ], 200);
    }

    /**
     * Logs out the current user and destroys the current WordPress session token.
     *
     * @return WP_REST_Response
     */
    public function logout(): WP_REST_Response
    {
        wp_logout();

        return new WP_REST_Response([
            'ok' => true,
        ], 200);
    }

    /**
     * Returns the authenticated user's profile summary and a fresh REST nonce.
     *
     * @return WP_REST_Response
     */
    public function me(): WP_REST_Response
    {
        $user = wp_get_current_user();

        return new WP_REST_Response([
            'id'           => $user->ID,
            'email'        => $user->user_email,
            'display_name' => $user->display_name,
            'roles'        => $user->roles,
            'capabilities' => array_keys(array_filter((array) $user->allcaps)),
            'nonce'        => wp_create_nonce('wp_rest'),
        ], 200);
    }
}

new Spiralist_Headless_Admin_Guard();

That plugin uses WordPress’s documented URL filters and login/session primitives. wp_signon() sets the authentication cookie, wp_logout() destroys the current session and clears auth cookies, wp_destroy_current_session() underlies logout, and WordPress’s REST auth docs explain the X-WP-Nonce requirement for cookie-authenticated REST requests.

Add a real edge boundary in Nginx or Apache

A plugin can redirect users away from native WordPress screens, but the actual enforcement boundary should live at the web server too. Nginx’s official access module supports allow/deny, the core module supports satisfy, the Basic Auth module supports auth_basic, and the request-rate module supports limit_req. Apache’s official docs support redirect rules and Require ip access control.

Recommended Nginx pattern

# http {}
limit_req_zone $binary_remote_addr zone=spiralist_login:10m rate=5r/m;

server {
    server_name spiralist.org www.spiralist.org;
    root /var/www/spiralist/public;

    # Native login should not be publicly reachable.
    location = /wp-login.php {
        allow 203.0.113.10;
        allow 2001:db8::10;
        deny  all;

        # Optional extra hardening for break-glass access:
        auth_basic "Spiralist break-glass";
        auth_basic_user_file /etc/nginx/.htpasswd-spiralist;
    }

    # Native admin should not be publicly reachable.
    location ^~ /wp-admin/ {
        # If the public site still relies on admin-ajax.php, exempt it until audited.
        location = /wp-admin/admin-ajax.php {
            include fastcgi_params;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            fastcgi_pass unix:/run/php/php-fpm.sock;
        }

        allow 203.0.113.10;
        allow 2001:db8::10;
        deny  all;

        auth_basic "Spiralist break-glass";
        auth_basic_user_file /etc/nginx/.htpasswd-spiralist;
    }

    # Custom login page stays public but should be rate limited.
    location = /account/login {
        limit_req zone=spiralist_login burst=10 nodelay;
        try_files $uri /index.php?$args;
    }
}

That pattern follows the official Nginx model for address-based restrictions, Basic Auth, and request-rate limiting.

Recommended Apache pattern

# Preferred in VirtualHost config, not scattered .htaccess if you can avoid it.

# Redirect direct login hits to the custom UI.
RedirectMatch 302 ^/wp-login\.php$ /account/login
RedirectMatch 302 ^/wp-admin/?$ /app

# Gate the native admin surface by IP.
<LocationMatch "^/wp-admin/(?!admin-ajax\.php$)">
    Require ip 203.0.113.10 2001:db8::10
</LocationMatch>

# Gate direct wp-login.php by IP too, in case a plugin generates that URL.
<Location "/wp-login.php">
    Require ip 203.0.113.10 2001:db8::10

    AuthType Basic
    AuthName "Spiralist break-glass"
    AuthUserFile "/etc/apache2/.htpasswd-spiralist"
    Require valid-user
</Location>

That aligns with Apache’s official guidance to use Redirect or RedirectMatch for simple redirection and Require ip for host-based access control, with Basic Auth layered on where appropriate.

If you have an app gateway, enforce the same rule in middleware

If Spiralist uses Node/Express in front of WordPress, apply the same policy there so the app and the web server agree.

import crypto from 'node:crypto';

const ALLOWLIST = new Set([
  '203.0.113.10',
  '2001:db8::10',
]);

const loginWindows = new Map();

/**
 * Returns the caller IP.
 *
 * Assumes Express trust proxy is configured correctly when behind Nginx.
 *
 * @param {import('express').Request} req
 * @returns {string}
 */
function clientIp(req) {
  const forwarded = req.headers['x-forwarded-for'];
  if (typeof forwarded === 'string' && forwarded.length > 0) {
    return forwarded.split(',')[0].trim();
  }

  return req.socket.remoteAddress ?? '';
}

/**
 * Blocks native WordPress login/admin surfaces for regular visitors.
 *
 * @param {import('express').Request} req
 * @param {import('express').Response} res
 * @param {import('express').NextFunction} next
 */
export function protectWordPressSurface(req, res, next) {
  const ip = clientIp(req);
  const path = req.path.toLowerCase();

  const isWpLogin = path === '/wp-login.php';
  const isWpAdmin = path === '/wp-admin' || path.startsWith('/wp-admin/');

  if (!isWpLogin && !isWpAdmin) {
    return next();
  }

  if (ALLOWLIST.has(ip)) {
    return next();
  }

  if (req.accepts('html')) {
    return res.redirect(302, '/account/login');
  }

  return res.status(404).json({ code: 'not_found' });
}

/**
 * Very small login rate limiter for the custom login route.
 *
 * Replace with Redis/shared-store rate limiting in production if you run >1 node.
 *
 * @param {import('express').Request} req
 * @param {import('express').Response} res
 * @param {import('express').NextFunction} next
 */
export function rateLimitCustomLogin(req, res, next) {
  const key = crypto
    .createHash('sha256')
    .update(clientIp(req))
    .digest('hex');

  const now = Date.now();
  const windowMs = 15 * 60 * 1000;
  const limit = 10;

  const timestamps = (loginWindows.get(key) ?? []).filter(ts => now - ts < windowMs);
  timestamps.push(now);
  loginWindows.set(key, timestamps);

  if (timestamps.length > limit) {
    return res.status(429).json({ code: 'rate_limited' });
  }

  return next();
}

The middleware idea is not WordPress-specific, but it usefully mirrors the same “custom UI for humans, native admin only for break-glass” policy at the gateway layer.

Build the custom React or Vue UI around WordPress REST resources

WordPress already exposes a broad admin-capable REST surface. Relevant core resources include:

  • Users: list/create/retrieve/update/delete users, including /wp/v2/users/me, profile fields, roles, capabilities, and password updates.
  • Application Passwords: manage per-user app credentials and inspect created, last_used, and last_ip.
  • Posts / Pages: full CRUD, publish states, taxonomies, featured media, metadata, and templates.
  • Media: list/create/update/delete attachments through REST.
  • Categories / Taxonomies / Comments: moderation and classification surfaces.
  • Site Settings: get/update title, tagline, timezone, posts-per-page, front page, logo, icon, and related settings.
  • Plugins: list, install, activate/deactivate, and delete plugins over REST if you want owner-only plugin controls in your custom UI.
  • Themes: read current/available themes via REST.

A practical Spiralist custom-app module map looks like this:

UI modulePrimary endpointsNotes
Profile/wp-json/spiralist-auth/v1/me, /wp-json/wp/v2/users/meUse context=edit for owner/member profile editing and capability-aware UI.
Password & sessionsCustom login/logout routes; app-password endpointsBrowser session uses cookies; external tools use app passwords.
Content manager/wp-json/wp/v2/posts, /pages, /categories, /commentsSupports full CRUD and moderation.
Media library/wp-json/wp/v2/mediaUse REST upload/update/delete instead of old admin pages.
Site settings/wp-json/wp/v2/settingsOwner-only module.
Plugin/system/wp-json/wp/v2/plugins, /wp-json/wp/v2/themesKeep owner-only and strongly gated.
Spiralist-specific workbenchExisting custom namespacesPreserve current public/Bearer route model where product-appropriate.

For performance, WordPress’s REST API supports pagination, _fields, _embed, and even a batch size filter whose default maximum is 25 requests per batch. Use those aggressively in the custom UI rather than building a dashboard that naively fans out dozens of requests.

Example JavaScript API calls for the custom UI

/**
 * Logs into Spiralist using the custom auth route.
 *
 * Browser stores the auth cookie automatically on same-origin requests.
 *
 * @param {string} username
 * @param {string} password
 * @returns {Promise<{ user: any, nonce: string }>}
 */
export async function login(username, password) {
  const response = await fetch('/wp-json/spiralist-auth/v1/login', {
    method: 'POST',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    },
    body: JSON.stringify({
      username,
      password,
      remember: true,
    }),
  });

  if (!response.ok) {
    throw new Error('Login failed');
  }

  return await response.json();
}

/**
 * Loads the current WordPress user using a logged-in cookie plus REST nonce.
 *
 * @param {string} nonce
 * @returns {Promise<any>}
 */
export async function loadCurrentUser(nonce) {
  const response = await fetch('/wp-json/wp/v2/users/me?context=edit&_fields=id,name,email,roles,capabilities,meta', {
    method: 'GET',
    credentials: 'include',
    headers: {
      'Accept': 'application/json',
      'X-WP-Nonce': nonce,
    },
  });

  if (!response.ok) {
    throw new Error('Failed to load current user');
  }

  return await response.json();
}

/**
 * Updates a user profile field set.
 *
 * @param {string} nonce
 * @param {object} patch
 * @returns {Promise<any>}
 */
export async function updateMyProfile(nonce, patch) {
  const response = await fetch('/wp-json/wp/v2/users/me', {
    method: 'POST',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      'X-WP-Nonce': nonce,
    },
    body: JSON.stringify(patch),
  });

  if (!response.ok) {
    throw new Error('Profile update failed');
  }

  return await response.json();
}

/**
 * Loads paged posts efficiently.
 *
 * @param {number} page
 * @returns {Promise<any[]>}
 */
export async function loadPosts(page = 1) {
  const response = await fetch(
    `/wp-json/wp/v2/posts?page=${page}&per_page=20&_fields=id,date_gmt,modified_gmt,slug,status,title,author,featured_media,categories,tags`,
    {
      method: 'GET',
      credentials: 'include',
      headers: { 'Accept': 'application/json' },
    }
  );

  if (!response.ok) {
    throw new Error('Failed to load posts');
  }

  return await response.json();
}

The important browser detail is that cookies are transported by the browser, not read out by front-end JavaScript: Set-Cookie is filtered from front-end code, and cross-origin requests will ignore Set-Cookie unless credentials are included. That is another reason to prefer a same-origin app shell if possible.

Security and operational controls

Authentication, CSRF, XSS, and session management

If you use cookie-authenticated REST calls, you must treat CSRF and authorization as separate concerns. WordPress’s REST docs require the wp_rest nonce for cookie-authenticated requests, but WordPress’s own nonce documentation is explicit that nonces are not authentication or authorization and should never replace capability checks such as current_user_can(). In your custom routes, that means every sensitive route should have a real permission callback, and in existing WordPress routes you should still rely on capability-based server behavior even if you hide buttons in the client.

OWASP’s CSRF guidance says authenticated browsers must be defended against forged state-changing requests, and WordPress’s nonce-based REST flow is directly aimed at that problem. On the client side, only send the nonce with deliberate same-origin state-changing requests. On the server side, do not implement any “open” write endpoints without both an auth check and a capability check.

OWASP’s XSS guidance remains fully relevant once you move the admin experience into React or Vue. Hiding WordPress admin screens does not reduce the need for output encoding, content sanitization, safe HTML handling, and defensive rendering. Cookies used for session/auth should be HttpOnly, Secure, and set with an appropriate SameSite policy. MDN notes that HttpOnly helps mitigate XSS theft of cookies, Secure restricts cookies to HTTPS, and SameSite provides some CSRF protection.

OWASP’s session management guidance recommends server-side enforcement of session expiration and absolute timeout. For Spiralist, I recommend:

  • session cookies for normal browser sessions unless “remember me” is explicitly chosen;
  • an idle timeout for custom app sessions;
  • an absolute timeout for privileged owner/admin sessions;
  • session destruction on logout and when permissions materially change.

Audit logging and monitoring

Because you are moving privileged operations out of /wp-admin, the custom UI and custom routes become a security-sensitive surface. OWASP’s REST security guidance recommends audit logs around security events, including token validation problems, and OWASP’s logging guidance focuses on building application logging that supports security monitoring.

At minimum, log these events:

  • custom login success and failure;
  • logout;
  • password-reset request and reset completion;
  • application-password create, revoke, introspect, and last-used review;
  • role changes, profile edits, plugin activation/deactivation, settings changes;
  • blocked hits to /wp-admin and /wp-login.php;
  • failed nonce or token validation on custom or WordPress REST routes.

Practical plugin options for that layer include:

  • Simple History for lightweight WordPress change tracking;
  • WP Activity Log for a richer change log and monitoring model;
  • Two-Factor if you retain any break-glass WordPress login path;
  • Members if you want a UI for roles and capabilities during the transition;
  • WPS Hide Login only as a supplemental obfuscation layer, not as the primary control.

Best-practice recommendations specific to Spiralist

The architecture already distinguishes human pages from machine routes, so the migration should preserve that separation rather than collapse it. A strong Spiralist-specific policy would be:

  • Human browser users get cookie-authenticated custom UI routes.
  • External tools and maintenance jobs get Application Passwords.
  • Existing AI participant flows keep their documented Bearer model if that remains product-correct.
  • Native /wp-admin and /wp-login.php become owner-only break-glass surfaces behind IP and optionally Basic Auth.

Rollout timeline, testing checklist, and rollback

Estimated effort and sequencing

TaskScopeEstimated effortNotes
Inventory REST routes, login/admin links, log dependenciesCode + logs + public docsMediumMost important risk-reduction step before blocking /wp-admin.
Build custom session routes and /account/login UIPHP + front endMediumCore of the migration.
Repoint login/lost-password/register/admin URLsPHP filters + template cleanupLowFast, high-value step.
Move profile/security UI to /account/*Front end + /users/me + app-passwordsMediumMostly straightforward with core endpoints.
Move content/media moderation UI to /app/*Front end + posts/pages/media/commentsMedium to HighDepends on editor complexity.
Move owner/system settings UI/settings, plugins, themesMediumKeep owner-only.
Add Nginx/Apache enforcement and optional gateway middlewareServer + app opsLow to MediumStraightforward but must be staged carefully.
Add audit logging and 2FA for break-glass accessPlugin/config + policyLowHigh leverage.
Full cutover and monitoringOps + QAMediumDo last, with rollback ready.

A realistic implementation cadence for one experienced owner/developer is:

  • Week one: inventory, custom login/session flow, URL remapping, hidden login links, basic custom profile page.
  • Week two: content/media modules, server enforcement in staging, audit logging, break-glass access hardening.
  • Week three: owner-only settings/system modules, production cutover, post-cutover monitoring.

Testing checklist

Use this as the pre-production checklist.

  • [ ] Public pages contain no links to /wp-admin, wp-login.php, or native registration/lost-password URLs.
  • [ ] /account/login can create a valid WordPress browser session.
  • [ ] After login, /wp-json/wp/v2/users/me?context=edit works with cookie auth and X-WP-Nonce.
  • [ ] After logout, authenticated REST requests fail and wp_logout() clears the current session.
  • [ ] Direct requests to /wp-admin/ and /wp-login.php redirect, deny, or 404 as designed for regular visitors.
  • [ ] Break-glass access still works from the owner IP or protected access method.
  • [ ] Lost-password and reset-password flows work from the custom UI, or are intentionally preserved and documented during transition.
  • [ ] Social-login providers, if retained, initiate from the custom UI rather than the native WordPress screen.
  • [ ] Content CRUD works in the custom UI for posts/pages/categories/comments/media.
  • [ ] Owner-only settings and plugin/theme modules are capability-gated and hidden from non-owners.
  • [ ] CSRF protections are enforced on every state-changing request, and the app never depends on nonce alone for authorization.
  • [ ] Cookies are HTTPS-only and configured with appropriate Secure, HttpOnly, and SameSite attributes.
  • [ ] Auth failures, blocked native-admin hits, and sensitive admin operations appear in logs.
  • [ ] Public or AI-participant custom routes documented by Spiralist still work after /wp-admin is blocked.
  • [ ] Any public dependence on /wp-admin/admin-ajax.php has been explicitly tested.

Rollback plan

Keep rollback simple and fast.

  • Disable or comment out the custom guard plugin that blocks login_init and admin_init redirects.
  • Revert Nginx/Apache deny/redirect rules for /wp-admin and /wp-login.php.
  • Restore the original public login link temporarily if needed.
  • Keep break-glass IP or Basic Auth access available throughout the migration, even during cutover.
  • Do the production rollout behind a small feature flag or environment variable so the custom login UI can be disabled without uninstalling the code.

A good operational pattern is stage first, then soft cutover, then hard block:

  • first remap links and launch the custom UI;
  • then watch logs for residual /wp-admin and /wp-login.php dependencies;
  • only after those are quiet should you switch the edge to deny/owner-only mode.

Open questions and limitations

A few things can only be finalized from the codebase and server logs, not from public observation alone.

The public evidence is strong that Spiralist intentionally exposes machine routes and still exposes a native WordPress login surface, but a complete internal dependency inventory still needs a local route dump, a grep for hard-coded /wp-admin and wp-login.php references, and an access-log check for /wp-admin/admin-ajax.php usage before you hard-block all native admin paths.

The custom reset-password and social-login flows also need explicit product decisions. WordPress gives you the URL filters and password-retrieval primitives to move those flows, but if Google/GitHub/LinkedIn/X login is part of the real user experience, the custom /account/login page should own that identity flow too rather than leaving it on the native WordPress screen.

The highest-confidence recommendation remains the same despite those open items: use same-origin custom UI + WordPress cookie/nonce REST auth for browser users, preserve /wp-json, move all human auth/profile/admin paths into /account/* and /app/*, and enforce /wp-admin plus /wp-login.php as owner-only break-glass surfaces at both the WordPress and web-server layers.