Developer docs
Start
Build with AI
Themes
Apps
- App development
- App development best practices
- Hooks and filters
- The App API
- Sending email from apps
- Admin bar for apps and themes
- Public routes for apps
- Admin screens, routes and UI components
- Dashboard widgets
- Data tables
- AJAX actions
- Media and uploads for apps
- App permissions
- Content types, blocks and widgets
- Content types, taxonomies and custom fields
- Search
- When an app fails: fault isolation and recovery
API and AI agents
Security and contributing
Security for developers
Basehim's security model is simple to state:
- roles grant capabilities;
- every way in (admin, REST, MCP, AJAX, app routes) 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
// On a public route: let core check it
$this->router()->site()->get('/reports', [$this, 'reports'], ['can' => 'edit_others_posts']);
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), or $e($value) in a template rendered by SitePage::view() |
| 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). Dashboard widgets that return data instead of HTML need no escaping at all: core draws every string as text.
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 |
Public routes from $this->router()->site() (POST, PUT, PATCH, DELETE) | Core: a request carrying the session cookie must send the token in X-CSRF-Token or _csrf. Turn it off only for server-to-server endpoints ('csrf' => false), which can't be combined with sign-in |
Your adminPost() routes | You: $this->session()->verifyCsrf((string) $request->input('_csrf')) |
Public routes from the older post()/route() | You, or move them to site() |
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.
Public routes
Since 1.2.72 core decides which URLs an app may answer, and in what order (see Public routes for apps):
- Core's routes always come first. An app can't answer a URL core serves; only the home page and search can be taken over, and only with an explicit option.
- No public route under
/admin. A route there would skip sign-in and the admin's permission checks, so it is refused, whichever helper registered it. Admin pages go throughadminGet()/adminPost(). - No catch-alls. A route starting with a parameter (
/{slug}) would answer every page, so it is refused. Reserved prefixes (/ajax,/api/v1,/mcp,/oauth,/uploads…) belong to core. - Malformed requests are refused before your handler runs: 400 for
./..segments, NUL bytes, encoded slashes in a parameter or a JSON body that doesn't parse; 405 for a method the path doesn't accept. - Per-route checks:
auth,can,csrfandrate, enforced by core in front of your handler. - Errors stay private. A handler that throws gives visitors a generic 500; the details go to your app's log.
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. - Public routes: a limit per route and visitor, set with
'rate' => [requests, seconds]; over it, 429 withRetry-After.
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.
What core does guarantee since 1.2.71 is fault isolation: an app's code is checked before it's loaded, and an app that fails while loading or starting is switched off, with its registrations removed, instead of taking the site down. The report is visible only to people with manage_apps, and messages are stripped of server paths and anything that looks like a credential. See When an app fails. This protects the site from a broken app, not from a malicious one.
Hardening checklist
- Serve the site over HTTPS.
- Keep the bundled
.htaccess. Since 1.3.0 it also refuses PHP'serror_logfile, which many hosts write into the site's folder and which has no extension, so the older.logrule missed it. On nginx, copy its denials,error_logincluded (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