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.

explanation

The security surface

Every row below is documented in depth on its own page. This table is the index.

ConcernMechanismAppliedProtects against
Browser/transport hardeningSecureController / SecurityHeadersAspectOpt-out, every responseClickjacking, MIME sniffing, protocol downgrade, lax referrer leakage, unrestricted resource loading
Session authenticationcanvas-authorizationOpt-in, scaffoldedUnauthenticated access to login-gated routes
Token authenticationJwtAuthenticationAspectOpt-in, per routeUnauthenticated or forged API access, missing scope checks
Re-authentication recencyStepUpAuthenticationAspectOpt-in, per routeSession hijacking / "left logged in" abuse on sensitive actions — being logged in doesn't prove when the credential was last proven
Cross-site request forgeryCsrfProtectionAspectOpt-in, per routeState-changing requests triggered from another origin on behalf of a logged-in user
Input validationValidateAspectOpt-in, per routeMalformed, missing, or out-of-range data reaching controller logic
Input sanitationSanitizeAspectOpt-in, per routeUnnormalized/dirty data (stray whitespace, encoding tricks, unwanted tags) flowing into storage or output
Abuse / fair useRateLimitAspectOpt-in, per routeBrute-forcing, scraping, and resource exhaustion from a single IP or user
File uploadsSecureUploadAspectOpt-in, per routeMalicious file content/extensions, path traversal, web-root execution of uploaded files
Cross-origin accessCorsSecurityHeaderPolicyOpt-in, added to security_header_policyUnintended 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:

ClassHeaders
BasicSecurityHeaderPolicySAMEORIGIN framing, 180-day HSTS, strict-origin-when-cross-origin referrer policy, no CSP — the framework-wide fallback when security_header_policy isn't set
MediumSecurityHeaderPolicySame framing, a 2-year HSTS, strict-origin, and a default-src 'self' CSP baseline — this is the tier SecureController pins
StrictSecurityHeaderPolicyDENY 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
NullSecurityHeaderPolicyNo-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
CorsSecurityHeaderPolicyOpt-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,
];
KeyMeaning
allowed_originsExact 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_methodsHTTP methods a cross-origin request may use. Also checked against the route's own matched methods during preflight — see below
allowed_headersRequest headers a cross-origin script may send, matched case-insensitively
exposed_headersResponse headers exposed to cross-origin scripts via Access-Control-Expose-Headers
allow_credentialsWhether to send Access-Control-Allow-Credentials: true for allowed origins
max_ageHow 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 shapeTypically layer on
Public read-only pageHeaders 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 requestRateLimitAspect (tight, IP-scoped) + CsrfProtectionAspect + ValidateAspect
Authenticated JSON APIJwtAuthenticationAspect (+ RequireScopeAspect if scoped) + RateLimitAspect (user-scoped) + ValidateAspect
API consumed from a browser on another originAll 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 endpointSecureUploadAspect + CsrfProtectionAspect, plus auth/step-up if the upload is tied to an account