Security
Security
CSRF, CORS, rate limiting and security headers — applied by default, not something to switch on manually on every route.
CSRF
Niang\Core\Csrf generates one token per session (32 random bytes) and compares it in
constant time (hash_equals()):
<form method="POST" action="/contact">
<?= csrf_field() ?>
<!-- ... -->
</form>
csrf_field() prints <input type="hidden" name="_token" value="...">.
App\Middleware\VerifyCsrfToken checks _token (form) or the
X-CSRF-Token header (JS request) on POST/PUT/PATCH/DELETE
— never on GET, which must not change anything:
$router->post('/contact', [ContactController::class, 'store'], [VerifyCsrfToken::class]);
A missing or invalid token responds with 419 (not 403 — a dedicated code, to tell an expired session apart from an authorization refusal) with a clear message rather than a generic error page.
CORS
config/cors.php defines the policy; Niang\Core\Cors computes the headers
(pure logic) and App\Middleware\HandleCors applies them, short-circuiting the preflight:
return [
'allowed_origins' => ['*'], // in production: ['https://app.example.com']
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With'],
'exposed_headers' => [],
'supports_credentials' => false, // true only with explicit origins, never '*'
'max_age' => 0,
];
$router->group(['prefix' => '/api', 'middleware' => [HandleCors::class]], function ($router) {
$router->get('/posts', [PostController::class, 'apiIndex']);
$router->options('/posts', fn () => Response::html('', 204)); // required: see Routing#head-options
});
Every API route needs its own explicit OPTIONS route: the Router does not generate
any automatically (see Routing), and without one the
preflight gets a 405 before it even reaches HandleCors.
supports_credentials: true forces the exact origin of the request to be reflected
rather than * — browsers reject the Access-Control-Allow-Origin: * +
credentials combination. Niang\Core\Cors goes further: with
supports_credentials: true, '*' in allowed_origins no longer
allows the request at all — only explicitly listed origins get through. Reflecting any origin while
keeping the active user's credentials would amount to disabling CORS for every site.
APP_KEY
The secret that signs URLs (password reset, email
verification), hashes API tokens and encrypts
cookies. composer create-project creates .env from
.env.example with an APP_KEY unique to the project, and so does
niang new; otherwise, ./bin/niang key:generate.
Without APP_KEY, these features throw a ConfigurationException rather than
using a default key: a key known to everyone would let anyone forge a valid reset link for any
account. Changing the key invalidates every outstanding signed URL, API token and cookie.
Encrypted cookies
Cookie::set('cart', json_encode($lines), 60 * 24 * 7); // minutes
Cookie::get('cart'); // the value, or null (missing, tampered with, expired)
Cookie::forget('cart');
The value is encrypted with AES-256-GCM (Niang\Core\Crypt, key derived from
APP_KEY): the browser can neither read nor modify it. The ciphertext is bound to the
cookie's name — a value valid for cart is rejected under another name — and carries its own
expiry date, checked server-side. Crypt::encrypt($text, $context) /
Crypt::decrypt($ciphertext, $context) can be used directly; decrypt() returns
null for any value that was modified or encrypted for another context.
PHP's session cookie only holds a random identifier: it is not encrypted, and does not need to be.
Rate limiting
Niang\Core\RateLimiter counts on disk (storage/framework/ratelimits/, no
Redis required). App\Middleware\ThrottleRequests applies it per IP and per route,
10 requests/minute by default:
$router->post('/login', [AuthController::class, 'login'], [VerifyCsrfToken::class, ThrottleRequests::class]);
Beyond the limit: 429 with a Retry-After header (seconds until the next
allowed attempt). Need a different limit elsewhere? Duplicate the class with your own values —
$maxAttempts/$decaySeconds are deliberately hard-coded in the class, not in
a generic configuration system.
Security headers and CSP
Applied to every response by Application::applySecurityHeaders() (see
request lifecycle) — secure by default rather than relying on
each route:
return [
'headers' => [
'X-Frame-Options' => 'DENY',
'X-Content-Type-Options' => 'nosniff',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Content-Security-Policy' => "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:",
'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains',
],
];
The default CSP forbids any inline script (no script-src 'unsafe-inline') — which is why
the shared design system (niang.css/niang.js, and this documentation
itself) loads everything from external .js/.css files rather than writing
inline JavaScript in the HTML. style-src 'unsafe-inline' remains allowed, for the
occasional style="" attributes of the demo themes. The list is an ordinary array in a
config file: adjust, add or remove an entry freely, nothing to re-register anywhere else.