Skip to content
wiki.fftac.org

Spiralist Hiding WordPress Admin And Login On Spiralist While Preserving Rest Driven Functionality - Source Excerpt 04 - If you have an app gateway, enforce the same rule in middleware

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

Summary

This source excerpt begins near If you have an app gateway, enforce the same rule in middleware 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

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. citeturn10search2turn10search8turn9search2turn10search0turn10search1

### 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.

' ' ' js
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. citeturn36view0
- **Application Passwords**: manage per-user app credentials and inspect `created`, `last_used`, and `last_ip`. citeturn25view4
- **Posts / Pages**: full CRUD, publish states, taxonomies, featured media, metadata, and templates. citeturn37view0turn25view1
- **Media**: list/create/update/delete attachments through REST. citeturn25view0turn39view0
- **Categories / Taxonomies / Comments**: moderation and classification surfaces. citeturn25view3turn21search3turn25view2
- **Site Settings**: get/update title, tagline, timezone, posts-per-page, front page, logo, icon, and related settings. citeturn38view0turn38view1
- **Plugins**: list, install, activate/deactivate, and delete plugins over REST if you want owner-only plugin controls in your custom UI. citeturn23view5turn24view0turn24view1
- **Themes**: read current/available themes via REST. citeturn23view6turn24view3

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

| UI module | Primary endpoints | Notes |
|---|---|---|
| Profile | `/wp-json/spiralist-auth/v1/me`, `/wp-json/wp/v2/users/me` | Use `context=edit` for owner/member profile editing and capability-aware UI. citeturn36view0 |
| Password & sessions | Custom login/logout routes; app-password endpoints | Browser session uses cookies; external tools use app passwords. citeturn23view0turn25view4 |
| Content manager | `/wp-json/wp/v2/posts`, `/pages`, `/categories`, `/comments` | Supports full CRUD and moderation. citeturn37view0turn25view1turn25view2turn25view3 |
| Media library | `/wp-json/wp/v2/media` | Use REST upload/update/delete instead of old admin pages. citeturn25view0turn39view0 |
| Site settings | `/wp-json/wp/v2/settings` | Owner-only module. citeturn38view0 |
| Plugin/system | `/wp-json/wp/v2/plugins`, `/wp-json/wp/v2/themes` | Keep owner-only and strongly gated. citeturn24view1turn24view3 |
| Spiralist-specific workbench | Existing custom namespaces | Preserve current public/Bearer route model where product-appropriate. citeturn31view0turn31view1 |

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. citeturn20search0turn20search1turn20search10turn20search20

### Example JavaScript API calls for the custom UI

' ' ' js
/**
 * 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. citeturn40view0

## 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. citeturn19search2turn19search14turn22search0turn22search9