Appearance
Security
Security headers, a Content Security Policy built from the browsers' reports, a journal of sign-ins and locks on addresses that guess passwords.
kitloom/security · admins: kitloom/security-filament, kitloom/security-nova
Video lessons
Recorded in the Filament admin; the Nova admin works the same way.
- Content Security Policy in Laravel, from report-only to enforce
- Security headers in Laravel: HSTS and X-Frame-Options
- Sign-in attempts in Laravel: lock the addresses that guess passwords
Security headers, a Content Security Policy, a journal of sign-ins and locks on addresses that guess passwords. A header is a line the server sends with a page, telling the browser what it may do with it. They close whole kinds of attack: another site showing yours in a frame to trick a visitor into clicking, an upload passing for a script, a script that got into a page sending your visitors' passwords elsewhere.
Installing the module is enough. It starts with what cannot break a site, and everything else waits on Security → Settings, which also shows what an answer carries now. Run php artisan migrate for the tables of the journal, the locks and the policy's reports. The admin section is kitloom/security-filament or kitloom/security-nova.
The headers
| Header | From the start | What it does |
|---|---|---|
Strict-Transport-Security | Off | Browsers that have seen the site keep to HTTPS |
X-Content-Type-Options | nosniff | Browsers take a file for what the server says it is |
X-Frame-Options | SAMEORIGIN | Only this site may show its pages in a frame |
Permissions-Policy | Camera, microphone and location closed; payments for the site itself | Browser features the site does not use stay closed to any script on it |
Cross-Origin-Opener-Policy | Off | A window the site opened cannot reach back into it |
Referrer-Policy | strict-origin-when-cross-origin | Set by the site itself (kitloom-site.referrer_policy), shown here for the full picture |
An answer that sets one of these itself keeps its own: the consent page of an AI agent's sign-in says DENY whatever you choose here.
Before you turn on HTTPS only
Strict-Transport-Security is the one header here that can hurt. A browser that has seen it refuses plain HTTP to the site until the time runs out, and nothing you do on the server takes that back. So, in this order:
- Make sure the whole site works over HTTPS, with a certificate that renews itself.
- Turn it on for 7 days, and use the site for those days.
- Raise it to 365 days.
- Turn on Include subdomains only if every subdomain works over HTTPS, also the ones this site does not serve, such as a mail or a staging address.
- Turn on Allow preloading only if you mean to submit the site to browsers' list. It goes out with subdomains and at least 365 days, and leaving that list takes months.
The header goes out only over HTTPS. If the settings page says it was held back though the site is on HTTPS, a proxy or a load balancer stands in front of the site and Laravel does not trust it yet: see Configuring trusted proxies.
Frames
This site only suits nearly every site: the Customize screen shows the page in a frame of the site itself. Nobody also stops Customize from showing the page. Choose Anyone if another site of yours has to frame this one.
Browser features
The text is written as the header is, for example camera=(), geolocation=(self), payment=(self "https://pay.example.com"). Left empty, it is kitloom-security.permissions_policy. Open a feature here if a theme or a script of yours needs it, such as geolocation=(self) for a "near me" search.
Opener policy
Off from the start, because a sign-in or a payment that opens a window of another site may stop working under it. Try Same site, windows it opens stay connected with your own sign-in and checkout before you keep it.
Content Security Policy
The policy says what a page may load and run: scripts only from this site or with the page's own secret mark (a nonce), and from the hosts your modules name. A script that gets into a page by a hole in a plugin or a stolen password then simply does not run, and cannot send anything away. It is the strongest header here, and the one that can stop something your site needs, so it comes in three steps on Security → Settings:
- Off from the start. A page stays exactly as it was.
- Report only. Nothing is stopped. Browsers tell the site what the policy would have stopped, and Security → Policy Reports lists it: what, under which directive, on which page, how many times. Use the site for a week, and go through your checkout, sign-in and forms yourself: a payment page you are sent to shows up here under
form-action. - Enforce. What the policy does not allow is stopped.
On the reports screen, Allow puts a host on the policy's list (Also allow on the settings page) and clears the reports it answers. Allow only what you recognise: a report of a host you do not know may be just what the policy is for. Dismiss deletes a report. Browser extensions report too; those are dropped before they reach the list.
What is already allowed
| What | How |
|---|---|
Your site's own files, and the host of ASSET_URL | Always |
| Pictures and video from anywhere | Posts link them from everywhere, and they run nothing |
| Inline styles | The block editor's markup and a server-drawn page are full of them |
| Frames from YouTube, Vimeo, X, Instagram, TikTok, Facebook, Spotify, SoundCloud and Google Maps | kitloom-security.csp.embeds |
| The counters of Analytics, the pixels of Ad Pixels, Tag Manager's container, Sentry, the captcha of Forms, a theme's fonts | Each module names its hosts, only for what is switched on |
Only the admin and the dashboards (Telescope, Horizon, Pulse) go without a policy: Filament and Nova need code a nonce does not reach. The other headers go out there too.
Scripts written into your content
A <script> or an onclick= written into a post's text is stopped when enforcing, and shows on the reports screen as inline with the page. A host cannot be allowed for it: move the script into a snippet (Header & Footer, whose scripts get the nonce) or a theme. If that has to wait, Allow inline scripts lets them run, and gives up most of what the policy protects.
Tag Manager
The module allows the container's own host. What the tags inside it load is known only to the container, so those hosts come through the reports. A custom HTML tag runs only if you pass the nonce to it, as Google's guide for a nonce-aware container describes.
Old http:// pictures
While enforcing over HTTPS, Upgrade insecure requests has browsers load the http:// pictures and files of old posts over HTTPS, which also removes the mixed content warnings of a site moved from WordPress.
Sign-ins
Security → Sign-in Attempts lists every try on the site's form and on the admin's: when, how it ended, with which login, from which address, in which form. Never a password. Lock this address on a line locks it.
The login forms already slow down guessing at one login from one address, and the module leaves that as it is. It adds what those cannot see: one address guessing across many logins and every form. By default:
- 5 wrong passwords in 15 minutes from an address lock it for 20 minutes.
- An address locked 4 times in a day waits 24 hours.
- Signing in with the right password starts its count anew, and so does waiting out a lock.
A locked address gets the same answer whether its password was right or not, so a lock tells a guesser nothing. Someone already signed in stays signed in. Security → Locked Addresses shows what is in force, lifts a lock, and locks an address or a whole network (203.0.113.0/24) by hand, for a time or for good. On the settings page, Never lock is for your office, and Always refuse for addresses that may never sign in. Both take addresses and networks.
If you lock yourself out
From the server, one command lets an address back in:
bash
php artisan security:unlock 203.0.113.5Behind a proxy
The settings page says which address the site sees your request coming from. If it is not yours, a proxy or a load balancer stands in front of the site and Laravel does not trust it yet: every visitor looks like one address, and one person's guessing would lock everybody out. Set up trusted proxies first. The site's own machine is never locked, so the plainest such case cannot lock everyone out.
What it does not cover
The REST API's application passwords keep their own limit (kitloom-api.wrong_passwords) and are not in the journal. The two-factor step keeps its own limit per user.
What is kept, and for how long
Attempts are kept 30 days and reports 14, and the rest goes every night (security:prune); both are settings. An attempt holds an address and a login, which may be someone's email, so it counts as personal data: kitloom/privacy exports and erases a person's lines. A lock names an address and no person, and stays until it ends.
The Activity Log goes on listing failed sign-ins as its audit trail, and Notifications' user.locked_out tells of one login's throttle. This module adds security.address_locked for a locked address, on by default.
For AI agents
With kitloom/ai, an agent has the tools security_report, security_login_attempts, security_locks, security_lock_address, security_unlock_address, security_csp_reports, security_csp_allow and security_settings_update, all behind manage_security, and is told the policy's way first: report, allow what the site uses, then enforce. It is told to ask you before HSTS and before enforcing, and to ask about any host in a report it cannot tie to something the site uses, since a report may name an attacker's host.
With the page cache
A page the page cache keeps has its headers with it, and they go out with the page, also when the page is served without starting the application. Its nonce goes with it, so markup and policy always agree; it changes when the page is drawn again, which any save of the page does. Saving an option here draws all those pages anew. Pages kept before the module was turned on have none until they are drawn again, so press Clear cache on Settings → Cache after turning it on.
For a developer
The options are security_*, written through SettingWriter with the manage_security permission (administrators). A value is checked once more when the header is made, so a row edited by hand never reaches a header. The configuration's filament.register and nova.register leave the section out of that admin; the headers go on being sent. Nothing is kept between requests, and each request gets its own nonce, so Octane needs nothing.
The policy is built on spatie/laravel-csp's Policy. A module says what it loads with a Spatie\Csp\Preset tagged Kitloom\Security\Csp\CspPresets::TAG, behind class_exists(CspPresets::class) and with kitloom/security in its suggest, and answers for what is switched on. An inline script of a module carries Vite::cspNonce(). An answer that sets its own Content-Security-Policy keeps it. Browsers report to POST /wp-json/security/v1/csp-reports: through report-uri, and through the Reporting API over HTTPS. Reports are capped per address and in the table.