Skip to content
prod 352bb92
Browse

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).

Steps at a glance:

  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.
  2. Verify HTTPS + headers — Confirm the redirect fires and every header is present, then run the external graders.
  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.
  4. Force HTTPS in production — Make the app generate https:// URLs in production so no link or asset downgrades the connection.
  5. Encrypt high-risk PII fields — Only if the User model carries high-risk PII (tax ID, bank account, SSN), cast those fields to Encrypted.
  6. Require strong passwords — Centralize the rule in AppServiceProvider::boot() — strict in production, relaxed locally.
  7. Lock certificate issuance with CAA (carried from Phase 4) — Phase 4 (DNS email records) records the CAA follow-up only when the certificate strategy is not final yet.

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"]

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.

  1. 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 On
    RewriteCond %{HTTPS} off
    RewriteRule ^(.*)$ 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/.htaccess ends with the HSTS, redirect, and header blocks.

A second header default trips people up the same way:

Confirm the redirect fires and every header is present, then run the external graders.

  1. 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 present
    curl -I http://<YOUR_DOMAIN>
    # Expected: 301 redirect to https://
    • ✅ All six headers print and HTTP returns a 301 to HTTPS.
  2. Confirm Grade A at securityheaders.com and SSL Labs (TLS) A/A+ where applicable. Drive the sites with browser automation when available; use the user browser only as fallback. The pragmatic CSP above includes 'unsafe-inline' for vendor admin panels — that caps securityheaders.com at Grade A, not A+, until you tighten script-src to 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.

If a verification route returns an unexpected 403 or 404, classify the layer with HTTP error triage before editing code. An intentional /install 403 usually belongs to .htaccess or Cloudflare WAF; asset 403/404 failures often point at symlink or permission state instead.

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.

Windowmax-ageNote
Month 0–631536000 (1 yr)Initial rollout
Month 6–1263072000 (2 yr)Confident config
Month 12+63072000 + submit to hstspreload.orgEffectively 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.

  1. 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.
  2. 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-permission
    php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
    # Only if Sanctum is not installed and you need API tokens:
    composer require laravel/sanctum
    php 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.
  3. Create a SecurityHeaders middleware if none exists.

    Terminal window
    php artisan make:middleware SecurityHeaders
    # Expected: app/Http/Middleware/SecurityHeaders.php created
    <?php
    namespace 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.
  4. Register it in the global middleware stack.

    // Laravel 10 — app/Http/Kernel.php
    protected $middleware = [
    // ... existing middleware
    \App\Http\Middleware\SecurityHeaders::class,
    ];
    • SecurityHeaders::class is registered in the global middleware stack for your Laravel version.

Make the app generate https:// URLs in production so no link or asset downgrades the connection.

  1. 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.

Only if the User model carries high-risk PII (tax ID, bank account, SSN), cast those fields to Encrypted.

  1. Cast the high-risk columns with the built-in encrypted cast.

    // app/Models/User.php — no import needed; 'encrypted' is a built-in cast string
    protected $casts = [
    'phone' => 'encrypted',
    'tax_id' => 'encrypted',
    ];
    • ✅ High-risk PII fields use the 'encrypted' cast (or this step is skipped as not applicable).

Centralize the rule in AppServiceProvider::boot() — strict in production, relaxed locally.

  1. 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 (Phase 4 carry-forward)

Section titled “7. Lock certificate issuance with CAA (Phase 4 carry-forward)”

Phase 4 (DNS email records) records CAA as a carry-forward only when the certificate strategy is not final yet. At this point Cloudflare/CDN and SSL should be locked, so verify the issuer and 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.

  1. 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 +short returns your real issuer, and a test cert renewal still succeeds.

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+ captured by browser automation when available.
  • 🤖 SecurityHeaders middleware — created + registered (or existing one confirmed).
  • 🤖 Production HTTPS + passwordsURL::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 (Phase 4 carry-forward) — issuance locked to the real CA(s); dig CAA confirms; renewal still succeeds — or a Zaj-BACKLOG.md row names the issuer-risk trigger, owner, review date, and acceptance criterion.