2 · Security headers & packages
Objective — make the app refuse plain HTTP and answer with a full security-header set: HSTS + the standard headers in .htaccess, the same headers re-asserted in a SecurityHeaders middleware, forced HTTPS in production, encrypted high-risk PII, and strong-password defaults — enough for Grade A at securityheaders.com with the pragmatic CSP below ('unsafe-inline' caps securityheaders.com at Grade A until you move to nonces/hashes).
Background
Section titled “Background”Headers are set in two layers that reinforce each other: the web server (.htaccess) handles HSTS and the HTTP→HTTPS redirect, and a Laravel middleware sets the application headers so they apply even when a route bypasses the rewrite. Verify SSL is live on every subdomain before you start — HSTS on a subdomain without a valid certificate locks users out.
flowchart LR Req["Browser request"] --> CF["Cloudflare<br/>(TLS · edge)"] CF --> HT[".htaccess<br/>HSTS + HTTP→HTTPS 301"] HT --> MW["SecurityHeaders middleware<br/>X-Frame · X-Content-Type · Referrer · Permissions · CSP"] MW --> App["Laravel app<br/>URL::forceScheme('https')"] App --> Resp["Response — all 6 headers, Grade A"]1. Add HSTS + the header set to .htaccess
Section titled “1. Add HSTS + the header set to .htaccess”Append to the end of public/.htaccess. HSTS and the redirect live here; the application headers are duplicated in the middleware (step 3) so they survive any route that skips the rewrite.
-
Append the HSTS block, the HTTP→HTTPS redirect, and the standard header set.
# HSTS + HTTPS enforcement<IfModule mod_headers.c>Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"</IfModule><IfModule mod_rewrite.c>RewriteEngine OnRewriteCond %{HTTPS} offRewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]</IfModule># Standard security headers — the canonical 6-header set (this page is the# authority; Phases 10/11/12 verify THIS exact set, Grade A baseline).<IfModule mod_headers.c>Header always set X-Frame-Options "SAMEORIGIN"Header always set X-Content-Type-Options "nosniff"Header always set Referrer-Policy "strict-origin-when-cross-origin"Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"# Real CSP, not just upgrade-insecure-requests. Vendor admin panels lean on# inline script/style, so 'unsafe-inline' is the pragmatic start; tighten# script-src to nonces/hashes once you've audited the vendor's inline JS.Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests"Header unset X-Powered-By</IfModule>- ✅
public/.htaccessends with the HSTS, redirect, and header blocks.
- ✅
A second header default trips people up the same way:
2. Verify HTTPS + headers
Section titled “2. Verify HTTPS + headers”Confirm the redirect fires and every header is present, then run the external graders.
-
Check the headers and redirect with
curl.Terminal window curl -sI https://<YOUR_DOMAIN> | grep -iE "(strict|x-frame|x-content|referrer|permissions|content-security)"# Expected: all six headers presentcurl -I http://<YOUR_DOMAIN># Expected: 301 redirect to https://- ✅ All six headers print and HTTP returns a 301 to HTTPS.
-
Confirm Grade A at securityheaders.com (👤) and SSL Labs (TLS) A/A+ where applicable. The pragmatic CSP above includes
'unsafe-inline'for vendor admin panels — that caps securityheaders.com at Grade A, not A+, until you tightenscript-srcto nonces/hashes. Record grades + date in your security notes.- ✅ securityheaders.com reads Grade A (A+ on that grader only after CSP nonce/hash hardening — not from
'unsafe-inline'alone); SSL Labs (TLS) A or A+ recorded with date.
- ✅ securityheaders.com reads Grade A (A+ on that grader only after CSP nonce/hash hardening — not from
HSTS progression timeline
Section titled “HSTS progression timeline”Ramp max-age as you gain confidence — a short window first, then preload once you’re sure every subdomain is HTTPS-only and will stay that way.
| Window | max-age | Note |
|---|---|---|
| Month 0–6 | 31536000 (1 yr) | Initial rollout |
| Month 6–12 | 63072000 (2 yr) | Confident config |
| Month 12+ | 63072000 + submit to hstspreload.org | Effectively permanent |
3. Audit, then add only the security packages you’re missing
Section titled “3. Audit, then add only the security packages you’re missing”On Laravel 10 middleware registers in app/Http/Kernel.php; the vendor package may already ship a security middleware, an auth-token package (Sanctum/Passport), or a permission package. Audit before you install anything — a second permission or token package conflicts with the one already there.
-
Audit what the vendor already ships.
Terminal window composer show | grep -E "(sanctum|permission|laratrust|bouncer|cors)"ls -la app/Http/Middleware/ | grep -i security# Expected: a list of any existing security / permission / token packages- ✅ You know which security packages and middleware already exist.
-
Install only the packages the audit shows are absent. Most CodeCanyon apps already bundle Sanctum/Passport and a permission package, so this block is usually a no-op.
Terminal window # Only if NO permission package exists (Spatie Permission, Laratrust, Bouncer):composer require spatie/laravel-permissionphp artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"# Only if Sanctum is not installed and you need API tokens:composer require laravel/sanctumphp artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"php artisan migrate# Expected: only the genuinely-missing packages install; migrations run clean- ✅ No duplicate permission/token package was added.
-
Create a
SecurityHeadersmiddleware if none exists.Terminal window php artisan make:middleware SecurityHeaders# Expected: app/Http/Middleware/SecurityHeaders.php created<?phpnamespace App\Http\Middleware;use Closure;use Illuminate\Http\Request;class SecurityHeaders{public function handle(Request $request, Closure $next){$response = $next($request);$response->header('X-Content-Type-Options', 'nosniff');$response->header('X-Frame-Options', 'SAMEORIGIN');$response->header('Referrer-Policy', 'strict-origin-when-cross-origin');$response->header('Permissions-Policy', 'geolocation=(), camera=(), microphone=()');$response->header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests");// HSTS is handled by .htaccess (step 1) + Cloudflare — do NOT set it here.// Set headers in ONE layer: if .htaccess already emits these, don't// double-set here — pick the layer your host actually runs (.htaccess// on Apache, this middleware where mod_headers is unavailable).return $response;}}- ✅ The middleware class exists and re-asserts the four application headers.
-
Register it in the global middleware stack.
// Laravel 10 — app/Http/Kernel.phpprotected $middleware = [// ... existing middleware\App\Http\Middleware\SecurityHeaders::class,];- ✅
SecurityHeaders::classis registered in the global middleware stack for your Laravel version.
- ✅
4. Force HTTPS in production
Section titled “4. Force HTTPS in production”Make the app generate https:// URLs in production so no link or asset downgrades the connection.
-
Force the scheme in
AppServiceProvider::boot().use Illuminate\Support\Facades\URL;public function boot(): void{if ($this->app->isProduction()) {URL::forceScheme('https');}}- ✅ Production URL generation forces
https.
- ✅ Production URL generation forces
5. Encrypt high-risk PII fields (optional)
Section titled “5. Encrypt high-risk PII fields (optional)”Only if the User model carries high-risk PII (tax ID, bank account, SSN), cast those fields to Encrypted.
-
Cast the high-risk columns with the built-in
encryptedcast.// app/Models/User.php — no import needed; 'encrypted' is a built-in cast stringprotected $casts = ['phone' => 'encrypted','tax_id' => 'encrypted',];- ✅ High-risk PII fields use the
'encrypted'cast (or this step is skipped as not applicable).
- ✅ High-risk PII fields use the
6. Require strong passwords
Section titled “6. Require strong passwords”Centralize the rule in AppServiceProvider::boot() — strict in production, relaxed locally.
-
Set password defaults and reference them in validation.
use Illuminate\Validation\Rules\Password;Password::defaults(fn () => $this->app->isProduction()? Password::min(12)->letters()->mixedCase()->numbers()->symbols()->uncompromised(): Password::min(8));- ✅
Password::defaults()enforces a strict production rule and a relaxed local one.
- ✅
Then reference Password::defaults() in validation: 'password' => ['required', 'confirmed', Password::defaults()]. For vendor apps, check whether registration validation already lives in a vendor file before editing.
7. Lock certificate issuance with CAA (deferred from Phase 4)
Section titled “7. Lock certificate issuance with CAA (deferred from Phase 4)”Phase 4 (DNS email records) deliberately deferred CAA until the certificate strategy was final. It is now: Cloudflare/CDN and SSL are locked, so add the CAA DNS records here. CAA restricts which certificate authorities may issue for your domain — a cheap defence against cert mis-issuance, but only safe once the issuing CA won’t change.
-
Add CAA records at your DNS provider for the issuer(s) actually in use (check Cloudflare → SSL/TLS → Edge Certificates, or your CA’s documented value, before adding).
example.com. CAA 0 issue "letsencrypt.org"example.com. CAA 0 issuewild "letsencrypt.org"example.com. CAA 0 iodef "mailto:security@example.com"- ✅
dig CAA example.com +shortreturns your real issuer, and a test cert renewal still succeeds.
- ✅
Checklist
Section titled “Checklist”Do not mark this step done until every box below is checked.
- 🤖 HSTS + redirect in
.htaccess— forwarded-proto rule if behind Cloudflare. - 🔀 Headers verified — all six present via
curl; Grade A at securityheaders.com; SSL Labs (TLS) A or A+ (👤 browser check). - 🤖
SecurityHeadersmiddleware — created + registered (or existing one confirmed). - 🤖 Production HTTPS + passwords —
URL::forceScheme('https')in production; strong-password defaults set. - 🤖 PII encrypted (if applicable) — high-risk fields use the
'encrypted'cast; APP_KEY rotation caveat noted. - 🔀 CAA records (deferred from Phase 4) — issuance locked to the real CA(s);
dig CAAconfirms; renewal still succeeds — or explicitly skipped + logged.