Appearance
Content Security Policy in Laravel, without breaking the site
A plugin has a hole, or a password leaks, and a line of JavaScript ends up in your checkout page. It reads what customers type and sends it to another host. Nothing on the server looks wrong, and the shop goes on selling.
A Content Security Policy stops exactly this. It is a header that tells the browser what a page may load and run, and a script the policy does not allow simply does not run. Everyone agrees it is a good idea, and few sites have one, because writing it by hand is the kind of job that breaks a site on a Friday: every tracker has its hosts, inline scripts need a nonce, and a page cache keeps yesterday's nonce.
kitloom/security takes it in three steps, and shows you what each one would do before it does it. Beside the policy it brings the other security headers, a journal of sign-ins, and locks on addresses that guess passwords.
Ask the agent
With kitloom/ai connected, an agent whose user may manage security does this through security_report, security_csp_reports, security_csp_allow, security_settings_update, security_login_attempts, security_locks, security_lock_address and security_unlock_address:
- "How does the site's security stand?"
- "Who has been guessing passwords this week? Lock that network."
- "Go through the policy's reports and tell me what the site really needs."
- "My office got locked out. Let 203.0.113.5 back in."
An agent is told the policy's way before it starts: report first, allow what the site uses, then enforce. It asks you before enforcing and before HSTS, and asks about any host it cannot tie to something the site uses. Anyone can send a report, so its text is data to the agent, never instructions. The key's user decides what it may do at all. How to connect one: Run your site from Claude or Cursor.
From report-only to enforce
On Security → Settings the policy starts Off, and a page stays exactly as it was: no header, not one changed byte of its markup.
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.
Allow. A report of a host you recognise has a button: Allow puts the host on the policy's list and clears the reports it answers. A host you do not recognise may be just what the policy is for. Code written into a page (inline, eval) has no host to allow, and the report says which page to look at.
Enforce. Once no new reports come in. A script from any other host, or one written into a post, no longer runs, and the same screen tells you when it happens. Browser extensions report too; those are dropped before they reach the list.
What is already allowed
| What | How |
|---|---|
The 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 | A list in the configuration |
| 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 |
That last line is what makes the job short. A site with Plausible on is not opened to Google, and a site that chose hCaptcha is not opened to Cloudflare. Your own snippets in Header & Footer get the page's nonce, and what they load shows up in the reports.
The admin goes without a policy: Filament and Nova need code a nonce does not reach. The other headers go out there too.
The other headers
Installing the module sends what cannot break a site, and What an answer carries now at the top of the settings page shows the result.
| Header | From the start | What it does |
|---|---|---|
X-Content-Type-Options | nosniff | An upload cannot pass for a script |
X-Frame-Options | SAMEORIGIN | Another site cannot show yours in a frame to trick a visitor into clicking |
Permissions-Policy | Camera, microphone and location closed | A script that got in cannot ask for them |
Strict-Transport-Security | Off | Browsers keep to HTTPS |
Cross-Origin-Opener-Policy | Off | A window the site opened cannot reach back into it |
HSTS starts off for a good reason: a browser that has seen it refuses plain HTTP until the time runs out, and nothing on the server takes that back. Start at 7 days, and raise it once nothing has gone wrong. If the page says the header was held back though the site is on HTTPS, a proxy in front of it is not trusted yet.
Who is guessing passwords
Security → Sign-in Attempts lists every try on the site's form and the admin's: when, how it ended, with which login, from which address. Never a password.
The login forms already slow down guessing at one login from one address. What they cannot see is one address guessing across many logins, which is what guessing looks like. So five wrong passwords in 15 minutes lock an address for 20 minutes on every form, and an address locked four times in a day waits a whole day. All of those numbers are settings. A locked address hears the same whether its password was right or not, so a lock tells a guesser nothing.
Security → Locked Addresses shows what is in force, lifts a lock, and locks an address or a whole network by hand. Never lock is for your office. If you ever lock yourself out:
bash
php artisan security:unlock 203.0.113.5A lock is news: it reaches you in Telegram, Slack or by email with the address and the logins it tried. Attempts are kept 30 days and go through Export and Erase Personal Data, since a login may be someone's email.
Two things stay as they were. The REST API's application passwords keep their own limit and are not in the journal, and the two-factor step keeps its own limit per user.
For a Laravel developer
It is spatie/laravel-csp's Policy, with the parts a CMS needs around it.
A module says what it loads with a Spatie\Csp\Preset tagged CspPresets::TAG, behind class_exists(), so neither package requires the other. Spatie's own presets serve where they fit. The module's Analytics does not use the Google Analytics one, which allows scripts from all of *.google.com, a host that serves JSONP.
The nonce is Vite's, the one Livewire, Filament and every module already put on their scripts. It is set per request only while a policy is on, so a worker under Octane never hands one visitor's nonce to the next.
The awkward part is the page cache, which keeps a page and sends it again without starting the application. A global middleware would put a fresh nonce on a page drawn with an old one and stop every script on it. So the header goes on in ResponsePrepared, inside the route, before the cache looks: a kept page goes out with the nonce its markup has, and saving an option draws the kept pages anew. A failed read of the options answers with the defaults, so a header never takes down an error page.
style-src keeps 'unsafe-inline' on purpose. Everything lives in security_* options, written through SettingWriter behind the manage_security permission, and each value is checked again when the header is made, so a row edited by hand never reaches a header.
Watch it
Try it
Open a sandbox in one click, with no sign-up: Filament or Nova. Every detail of the module is in the Security guide.