Skip to content

Security for developers

5 min read

Basehim's security model is simple to state:

  • roles grant capabilities;
  • every way in (admin, REST, MCP, AJAX) checks the same capabilities;
  • core handles the dangerous plumbing for you: CSRF, filtered HTML, uploads and rate limits.

This page covers what you can rely on, and what your theme or app still has to do.

Roles and capabilities

CapabilityAdminEditorAuthorContributorSubscriber
read✓✓✓✓✓
edit_posts, delete_posts✓✓✓✓
publish_posts✓✓✓
upload_media✓✓✓
edit_others_posts✓✓
edit_pages, publish_pages, edit_others_pages, delete_pages✓✓
manage_taxonomies, moderate_comments, unfiltered_html✓✓
delete_others_posts, delete_others_pages, delete_media✓
manage_users, manage_settings, manage_menus, manage_seo✓
manage_apps, manage_themes, manage_options✓

super_admin has every capability. Custom roles can be built under Users → Roles. Each user can also be granted or denied single capabilities on their edit screen, and that includes access_app:{slug}, which opens one app's admin screens.

Check capabilities in your code:

// In an app
$user = $this->api()->users()->current();
if (!$this->api()->users()->can($user, 'edit_others_posts')) { /* refuse */ }

// In an AJAX handler
$ctx->authorize('moderate_comments');      // throws FORBIDDEN if missing

A capability says what kind of thing a user may do. Whether they may touch this record is still your check: compare author_id with the current user, and allow others' records only with edit_others_*.

Filtered HTML (unfiltered_html)

The public site and the admin share an origin. A script in a post would run for any administrator who opens it. So HTML from anyone without unfiltered_html (everyone below editor) is filtered when it's saved:

  • scripts, styles and embedded objects are removed with their contents;
  • on… event handlers are removed;
  • links and sources must be http, https, mailto, tel or relative;
  • iframes are allowed only from known embed providers (YouTube, Vimeo, Spotify, Google Maps and others), over https;
  • inline styles lose position and anything that can load or run code.

This applies however the post arrives: the editor, REST, MCP, or your app's $this->api(). The writer is told what was removed. Two filters let apps adjust it: post.unfiltered_html and html.iframe_hosts (see Hooks and filters).

Escaping output

Core escapes its own output. Yours is your job. In templates and views:

ContextUse
HTML text and attributeshtmlspecialchars($value)
URLs you buildhtmlspecialchars($base . $path); check that user-supplied URLs start with https://, http:// or /
JavaScript datajson_encode() with the JSON_HEX_TAG, JSON_HEX_AMP, JSON_HEX_APOS and JSON_HEX_QUOT flags
In the browserelement.textContent = value, never innerHTML

$post['content'] is the one value you print as is: core has already rendered it to HTML (and filtered it on save).

CSRF

WhereWho checks
Core admin formsCore
AJAX actions ($this->ajax())Core, from the X-CSRF-Token header
Comment form (bh_comment_form())Core
Your adminPost() routesYou: $this->session()->verifyCsrf((string) $request->input('_csrf'))
Your public post() routesYou, or use an AJAX action

Tokens rotate when someone signs in or out. As defence in depth, AJAX requests from another origin are refused before the token is checked, and the session cookie is SameSite=Lax and HttpOnly.

SQL

Use bound parameters. Never build SQL from input:

// Yes
$this->db()->select("SELECT * FROM `{$this->table('votes')}` WHERE post_id = :id", ['id' => $postId]);

// No
$this->db()->select("SELECT * FROM `{$this->table('votes')}` WHERE post_id = " . $_GET['id']);

Table and column names can't be bound. Build them only from constants or $this->table(), never from input. Prefer $this->api() for core data: it runs the same checks as the admin.

Uploads

  • Only the file types allowed under Settings → Media are accepted, up to the size set there.
  • The stored file type comes from the file's content (fileinfo), not from what the browser claimed.
  • SVGs with scripts, event handlers or embedded links are refused, and SVGs are served with a sandboxing Content-Security-Policy.

Don't write uploaded files anywhere yourself. Use the media API (uploadFromPath() or POST /api/v1/media) and never trust a client-supplied file name, type or path.

Rate limits and sign-in protection

  • Sign-in: after a number of failures (set under Settings → Authorization, 10 by default), an account is locked for a while and a captcha appears. The API's /auth/login shares the lockout.
  • Two-step sign-in: an emailed code can be required, and it's enforced on /auth/login too.
  • AJAX: a global limit per client address, plus a limit per action that you set with rate_limit.

Behind a proxy or CDN, set TRUSTED_PROXIES so the AJAX gateway sees real client addresses (Configuration).

Tokens and keys

  • API keys start with basehim_. Only a hash is stored, so a key is shown once when it's created. A key acts as its creator, limited to its scopes, and stops working at once when revoked or when the account is suspended.
  • JWTs are signed with JWT_SECRET and last 15 minutes. Refresh tokens last 14 days, work once, and are rotated on every refresh.
  • OAuth (MCP) tokens last 8 hours, and refresh for up to 30 days.

Rotating JWT_SECRET signs everyone out of the API. Rotate it, along with the database password and APP_KEY, if .env ever leaks.

Apps are trusted code

An app is PHP in the same process as core. App permissions make an app's intentions visible and stop honest apps from overreaching by accident. They're not a sandbox. Install apps you trust, and review a new version's permissions before approving them.

Hardening checklist

  • Serve the site over HTTPS.
  • Keep the bundled .htaccess. On nginx, copy its denials (Installation).
  • Give storage/ and content/ 775, never 777.
  • Set a long random JWT_SECRET and APP_KEY.
  • Keep APP_DEBUG=false on live sites.
  • Give people and agents the least powerful role that works.
  • Give each integration its own API key with minimal scopes.
  • Keep core, apps and themes updated (Admin → Updates).

Reporting a vulnerability

Please don't open a public issue. Report it privately through GitHub's Report a vulnerability button on the Basehim repository. Include the version, steps to reproduce and the impact. Fixes ship in a patch release, and reporters are credited unless they prefer otherwise.

Keep building

More guides, or jump straight into the API reference.

Discussion

Leave a comment