Vertext implements multiple layers of security. This document describes each mechanism and how to use it correctly in your modules and controllers.
All state-changing requests (POST, PUT, DELETE) must include a valid CSRF token. Tokens are cryptographically secure (32 random bytes, hex-encoded), stored in the session, and validated with timing-safe comparison.
Use the csrf_field() helper or directly use the CSRF class:
<!-- In any HTML form -->
<form method="POST" action="/admin/my-module/store">
<?= csrf_field() ?>
<!-- ... -->
</form>
<!-- Or manually -->
<?= \Core\Security\CSRF::getTokenInput() ?>Call $this->validateCsrf() at the top of every POST handler:
public function store(): void
{
$this->validateCsrf(); // aborts with 419 if token is invalid
// ... safe to process
}BaseController::validateCsrf() handles this automatically. Tokens expire after 1 hour.
// Abort with 403 if the authenticated user lacks the permission
$this->requirePermission('posts.create');
// Check without aborting
if (Auth::can('posts.publish')) {
// show publish button
}
// Check role
if (Auth::hasRole('editor')) { ... }<?php if (Auth::can('posts.delete')): ?>
<button class="btn btn-danger">Delete</button>
<?php endif; ?>On login, the user's effective permissions (union of all assigned roles) are loaded from the database into the session. Auth::can() reads from the session - no DB query per check.
- Passwords are hashed with bcrypt (cost 12) via
password_hash()/password_verify(). LoginRateLimiterblocks accounts after repeated failures.- On successful login,
session_regenerate_id(true)prevents session fixation.
Sessions are configured with:
HttpOnlycookie - not accessible via JavaScriptSecurecookie - only sent over HTTPS (whenhttps => truein config)SameSite=Strict- prevents CSRF via cross-site requests- Session hijacking detection: stores user-agent and IP; mismatches are logged
use App\CMS\Auth;
Auth::check() // bool - is any user logged in?
Auth::user() // stdObject|null - current user record
Auth::id() // int|null - current user ID
Auth::can('slug') // bool - has permission?
Auth::hasRole('slug')// bool - has role?
Auth::logout() // destroy session, redirect to loginAll user input is sanitized by default via htmlspecialchars() with ENT_QUOTES | ENT_HTML5:
// Sanitized (default - safe for HTML output)
$title = $this->input->post('title');
// Raw - only when you need unescaped content (e.g. Quill rich text)
$body = $this->input->post('body', false);Never output raw user input directly into HTML without using the template system's {{var}} escaping.
All database queries use PDO prepared statements with bound parameters. The ORM query builder parameterizes all values.
Always use the ORM or parameterized queries:
// Safe - ORM parameterizes automatically
$this->db->table('posts')->where('slug', $slug)->first();
// Safe - manual prepared statement
$this->db->query("SELECT * FROM posts WHERE id = :id", [':id' => $id]);
// NEVER do this
$this->db->query("SELECT * FROM posts WHERE slug = '{$slug}'"); // DANGERWhen handling file uploads (via the Media module or custom code):
- File MIME type is validated against an allowlist
- File extension is validated separately from MIME type
- Uploaded files are stored with randomized names (
timestamp_randombytes.ext) - Upload directory contains an
.htaccessthat blocks PHP execution - Files are organized by
year/month/to limit directory size
Every state-changing operation should call $this->audit():
$this->audit(
'post.created', // action string
'post', // resource type
$newPostId, // resource ID (or null)
['title' => $title] // extra context (stored as JSONB)
);Audit logs are stored in the audit_logs table and visible in the Dashboard.
Core\Middleware\SecurityHeadersMiddleware applies a baseline to every response - admin, public
front-end, and the REST API alike:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; frame-ancestors 'none'
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=31536000; includeSubDomains is added on top of this when
'https' => true in config - the same flag that activates the Secure session cookie above.
Admin views still rely on inline <script>/<style> blocks, so BaseController::adminRender()
re-emits Content-Security-Policy with 'unsafe-inline' allowed on script-src/style-src
immediately before rendering - a later header() call for the same header name replaces the
earlier one, so this override applies to admin responses only. The public front-end and API keep
the stricter, no-unsafe-inline' policy.
By default, Client::getIpAddress() always returns REMOTE_ADDR (the direct TCP connection). Proxy forwarding headers (X-Forwarded-For, CF-Connecting-IP) are only trusted if you explicitly configure trusted proxies:
use Core\Http\Client;
Client::setTrustedProxies(['10.0.0.10']); // your load balancer IPNever configure this unless you know your infrastructure setup.
Storage/db.php and Storage/app.php contain credentials and are gitignored. Never commit them. In production:
- Use strong PostgreSQL passwords
- Set
env => 'production'inConfig/Config.php - Ensure
Logs/andCache/are not web-accessible - Remove or disable the
/setuproute after installation (it's automatically blocked onceinstalled.lockexists)