Security for developers
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
| Capability | Admin | Editor | Author | Contributor | Subscriber |
|---|---|---|---|---|---|
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,telor relative; - iframes are allowed only from known embed providers (YouTube, Vimeo, Spotify, Google Maps and others), over https;
- inline styles lose
positionand 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:
| Context | Use |
|---|---|
| HTML text and attributes | htmlspecialchars($value) |
| URLs you build | htmlspecialchars($base . $path); check that user-supplied URLs start with https://, http:// or / |
| JavaScript data | json_encode() with the JSON_HEX_TAG, JSON_HEX_AMP, JSON_HEX_APOS and JSON_HEX_QUOT flags |
| In the browser | element.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
| Where | Who checks |
|---|---|
| Core admin forms | Core |
AJAX actions ($this->ajax()) | Core, from the X-CSRF-Token header |
Comment form (bh_comment_form()) | Core |
Your adminPost() routes | You: $this->session()->verifyCsrf((string) $request->input('_csrf')) |
Your public post() routes | You, 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/loginshares the lockout. - Two-step sign-in: an emailed code can be required, and it's enforced on
/auth/logintoo. - 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_SECRETand 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/andcontent/775, never 777. - Set a long random
JWT_SECRETandAPP_KEY. - Keep
APP_DEBUG=falseon 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.
Leave a comment