Security Overview
Canvas's security model is a set of independent protections, each aimed at one specific concern — authentication, CSRF, rate limiting, input handling, file uploads, and browser-level header hardening. Most of these are opt-in aspects you compose per route via @InterceptWith; the one exception is the header policy, which is resolved once by the Kernel and enforced on every response — routed or error, with no opt-in required. This page is the map: what each piece protects against, how the always-on header policy works, and how to combine the opt-in aspects on a given route.
The security surface
Every row below is documented in depth on its own page. This table is the index.
| Concern | Mechanism | Applied | Protects against |
|---|---|---|---|
| Browser/transport hardening | SecureController / SecurityHeadersAspect | Opt-out, every response | Clickjacking, MIME sniffing, protocol downgrade, lax referrer leakage, unrestricted resource loading |
| Session authentication | canvas-authorization | Opt-in, scaffolded | Unauthenticated access to login-gated routes |
| Token authentication | JwtAuthenticationAspect | Opt-in, per route | Unauthenticated or forged API access, missing scope checks |
| Re-authentication recency | StepUpAuthenticationAspect | Opt-in, per route | Session hijacking / "left logged in" abuse on sensitive actions — being logged in doesn't prove when the credential was last proven |
| Cross-site request forgery | CsrfProtectionAspect | Opt-in, per route | State-changing requests triggered from another origin on behalf of a logged-in user |
| Input validation | ValidateAspect | Opt-in, per route | Malformed, missing, or out-of-range data reaching controller logic |
| Input sanitation | SanitizeAspect | Opt-in, per route | Unnormalized/dirty data (stray whitespace, encoding tricks, unwanted tags) flowing into storage or output |
| Abuse / fair use | RateLimitAspect | Opt-in, per route | Brute-forcing, scraping, and resource exhaustion from a single IP or user |
| File uploads | SecureUploadAspect | Opt-in, per route | Malicious file content/extensions, path traversal, web-root execution of uploaded files |
| Cross-origin access | CorsSecurityHeaderPolicy | Opt-in, added to security_header_policy | Unintended cross-origin reads of responses, overly broad preflight grants |
Security headers
Security headers are the browser/transport-hardening row from the table above — the one piece of the security surface that isn't an opt-in aspect. The Kernel resolves one policy class, or an ordered list of them, and applies it to every response, with no @InterceptWith annotation needed.
Why they're not opt-in
Headers are the one piece Canvas decided is too important to leave to opt-in — trusting every controller to remember basic browser hardening is a risk the framework isn't willing to take, so it's on by default instead. CSRF checks, authentication, rate-limit thresholds, and validation rules don't get the same treatment because there's no single setting that's correct for every route — whether a route needs them, and with what configuration, depends on what the route actually does, so those stay a deliberate per-route choice.
Configuring the policy
The Kernel resolves config/app.php's security_header_policy key and applies it to every response — including error pages that never touched a controller — at the single point every response passes through before being sent. That key is how you change the app-wide default; set it to any class implementing SecurityHeaderPolicyInterface. Canvas ships five — four header tiers plus the opt-in CorsSecurityHeaderPolicy described below:
| Class | Headers |
|---|---|
BasicSecurityHeaderPolicy | SAMEORIGIN framing, 180-day HSTS, strict-origin-when-cross-origin referrer policy, no CSP — the framework-wide fallback when security_header_policy isn't set |
MediumSecurityHeaderPolicy | Same framing, a 2-year HSTS, strict-origin, and a default-src 'self' CSP baseline — this is the tier SecureController pins |
StrictSecurityHeaderPolicy | DENY framing, a locked-down CSP, no-referrer, a 2-year preloaded HSTS, plus Permissions-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy, and X-Permitted-Cross-Domain-Policies |
NullSecurityHeaderPolicy | No-op — adds no headers at all. Set this explicitly as security_header_policy or a route's policyClass to opt out rather than leaving the setting unset |
CorsSecurityHeaderPolicy | Opt-in, deny-by-default — adds CORS approval headers (Access-Control-Allow-Origin, etc.) only for the exact origins listed in config/cors.php. See Cross-Origin Resource Sharing (CORS) below |
For example, to make StrictSecurityHeaderPolicy the app-wide default:
// config/app.php
return [
// ...
'security_header_policy' => \Quellabs\Canvas\Security\Policies\StrictSecurityHeaderPolicy::class,
]; Combining policies
security_header_policy also accepts an ordered, numerically indexed array of classes instead of a single one. Each policy is applied to the same response in declared order — this is how an application adds CORS on top of its header tier, since CorsSecurityHeaderPolicy doesn't add any of the Basic/Medium/Strict headers itself:
// config/app.php
return [
// ...
'security_header_policy' => [
\Quellabs\Canvas\Security\Policies\BasicSecurityHeaderPolicy::class,
\Quellabs\Canvas\Security\Policies\CorsSecurityHeaderPolicy::class,
],
]; A single class still behaves exactly as before — there's no need to wrap it in an array just to use one policy. Order matters: every built-in policy only sets a header when one isn't already present, so whichever policy runs first for a given header wins. An explicit null, an empty array, an associative or nested array, a missing class, or a class not implementing SecurityHeaderPolicyInterface are all rejected at startup rather than silently ignored. NullSecurityHeaderPolicy composes like any other entry — including it alongside others doesn't suppress the rest, it just contributes nothing itself.
A route's SecurityHeadersAspect override (see Secure Controller) accepts the same single-class-or-array syntax in its policyClass parameter, including directly in the annotation:
/**
* @InterceptWith(SecurityHeadersAspect::class, policyClass={
* Quellabs\Canvas\Security\Policies\StrictSecurityHeaderPolicy::class,
* Quellabs\Canvas\Security\Policies\CorsSecurityHeaderPolicy::class
* })
*/ A route override replaces the entire application-wide list, not just one entry — a route that needs both strict headers and CORS must list both, even if the app-wide default already includes CorsSecurityHeaderPolicy.
Cross-Origin Resource Sharing (CORS)
CorsSecurityHeaderPolicy is an opt-in SecurityHeaderPolicyInterface implementation that grants cross-origin access to the exact origins listed in config/cors.php. Adding the class to security_header_policy grants nothing on its own — the generated config defaults every allowlist to empty, so the policy is deny-by-default until configured.
Setting up config/cors.php
Generate the config file with the bundled Sculpt command:
php sculpt cors:init This creates config/cors.php with empty allowlists, ready to fill in:
// config/cors.php
return [
'allowed_origins' => ['https://app.example.com', 'https://admin.example.com:8443'],
'allowed_methods' => ['GET', 'POST', 'PATCH', 'DELETE'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-CSRF-Token'],
'exposed_headers' => ['Location'],
'allow_credentials' => true,
'max_age' => 600,
]; | Key | Meaning |
|---|---|
allowed_origins | Exact scheme://host or scheme://host:port origins granted access. Wildcard * and the opaque null origin are rejected — both here and against the request's actual Origin header |
allowed_methods | HTTP methods a cross-origin request may use. Also checked against the route's own matched methods during preflight — see below |
allowed_headers | Request headers a cross-origin script may send, matched case-insensitively |
exposed_headers | Response headers exposed to cross-origin scripts via Access-Control-Expose-Headers |
allow_credentials | Whether to send Access-Control-Allow-Credentials: true for allowed origins |
max_age | How long, in seconds, a browser may cache a preflight result. 0 omits Access-Control-Max-Age |
Then add the policy to security_header_policy alongside a header tier, the same way shown under Combining policies above.
Ordinary requests
For a request carrying an Origin header that matches allowed_origins exactly (scheme, host, and port), the policy sets Access-Control-Allow-Origin, plus Access-Control-Allow-Credentials and Access-Control-Expose-Headers if configured. A disallowed origin gets none of these — the response status and body are left completely unchanged. Either way, Vary: Origin is merged into any existing Vary value, so caches don't serve one origin's response to another.
Preflight requests
A preflight (OPTIONS with Access-Control-Request-Method) is approved only when the origin, the requested method, and every requested header are all allowed. The requested method must appear both in allowed_methods and in the Allow header Canvas's router already generated for that route — a method your app doesn't actually support there is never granted, even if it's in allowed_methods. An approved preflight returns the full configured method and header lists, not reduced to just what was requested, since the browser may reuse its cached preflight result for another permitted combination on the same URL.
If a route's explicit OPTIONS handler doesn't set an Allow header, preflight access is never granted implicitly — that route must supply one listing the methods it actually accepts.
Interaction with route-owned headers
If a response already carries Access-Control-Allow-Origin — set by the controller or an earlier policy in the list — CorsSecurityHeaderPolicy treats that as a decision the route already made and leaves it alone, rather than replacing it or adding conflicting approval headers.
Choosing what to layer on a route
Aspects stack, and the aspects you pick for a given route run in a deliberate order — stop abuse before doing any real work, confirm the request is genuine and recently authenticated, then validate and sanitize the payload before the controller ever sees it. That ordering is controlled by each @InterceptWith annotation's priority parameter; see Aspect-Oriented Programming for how priority and inheritance interact. Match the route's shape against the table below for a starting combination, then adjust based on what the route actually handles.
| Route shape | Typically layer on |
|---|---|
| Public read-only page | Headers only (the default) — nothing else usually applies |
| Public form submission (contact form, newsletter signup) | CsrfProtectionAspect + ValidateAspect + SanitizeAspect, usually RateLimitAspect to stop spam |
| Login / password-reset request | RateLimitAspect (tight, IP-scoped) + CsrfProtectionAspect + ValidateAspect |
| Authenticated JSON API | JwtAuthenticationAspect (+ RequireScopeAspect if scoped) + RateLimitAspect (user-scoped) + ValidateAspect |
| API consumed from a browser on another origin | All of the above, plus CorsSecurityHeaderPolicy added to security_header_policy (app-wide, or in the route's own override alongside its header tier) |
| Sensitive account action (password/email change, payment method, deleting data) | StepUpAuthenticationAspect + CsrfProtectionAspect + ValidateAspect + RateLimitAspect, extend SecureController |
| File upload endpoint | SecureUploadAspect + CsrfProtectionAspect, plus auth/step-up if the upload is tied to an account |