Skip to content
prod e051e98
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).

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

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.

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.

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

  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+ (👤 browser check).
  • 🤖 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 (deferred from Phase 4) — issuance locked to the real CA(s); dig CAA confirms; renewal still succeeds — or explicitly skipped + logged.