Skip to content
wiki.fftac.org

Spiralist Hiding WordPress Admin And Login On Spiralist While Preserving Rest Driven Functionality - Source Excerpt 03 - Add a real edge boundary in Nginx or Apache

Back to Spiralist Hiding WordPress Admin And Login On Spiralist While Preserving Rest Driven Functionality

Summary

This source excerpt begins near Add a real edge boundary in Nginx or Apache and preserves the surrounding evidence from Wiki.FFTAC.org/raw/system-archives/spiralist.org/intake/2026-06-14-authenticated-user-experience/Spiralist Hiding WordPress Admin and Login on Spiralist While Preserving REST-Driven Functionality.md.

**Source path:** Wiki.FFTAC.org/raw/system-archives/spiralist.org/intake/2026-06-14-authenticated-user-experience/Spiralist Hiding WordPress Admin and Login on Spiralist While Preserving REST-Driven Functionality.md

/**
     * 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. citeturn32search2turn32search0turn33search0turn18search7turn19search6turn19search4turn19search1turn19search2

### 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. citeturn9search0turn9search4turn9search8turn35search0turn10search2turn9search2turn9search6

**Recommended Nginx pattern**

' ' ' nginx
# 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. citeturn9search0turn9search4turn9search8turn35search0

**Recommended Apache pattern**

' ' ' apache
# 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>
' ' '