{} CodeLift

Hardening the shared Laravel starter-kit backend

Laravel's official starter kits (React / Vue / Livewire) differ in their frontend layer, but they share the same Laravel + Fortify backend code. So most of the production-hardening work is identical across all three. This is the pillar art…

Pub 2026-04-18 Verified 2026-04-19 Upd 2026-05-18

Verification environment

  • PHP 8.5.5
  • Laravel 13.x
  • Composer 2.9.7
  • Node 22.22.2
  • npm 10.9.7
  • Database SQLite (tests)
  • OS Docker Desktop (php:8.5-cli-bookworm)

Hardening the shared Laravel starter-kit backend

Laravel's official starter kits (React / Vue / Livewire) differ in their frontend layer, but they share the same Laravel + Fortify backend code. So most of the production-hardening work is identical across all three. This is the pillar article that collects that shared work — the backend improvements CodeLift applied to all three forks — in one place.

Each kit's own specifics (build setup, frontend pitfalls, the per-fork commit list) live in their cluster articles:

Why this article exists

CodeLift verifies official sample code in Docker and publishes production-hardened forks. Verifying the three starter kits showed that six backend-level findings (B–I below) resolve to exactly the same code, the same fix in every kit. Duplicating that explanation across three articles is worthless to readers and to search engines, so the shared part is consolidated here.

Verification environment, common to all forks:

Item Value
Base image php:8.5-cli-bookworm + Node 22
PHP 8.5.5
Laravel Framework 13.x
Composer 2.9.7
Verification date 2026-04-19
Environment Docker Desktop (no language runtime on the host)

Each finding below is recorded as: symptom → why it matters → fix → verification.

B. .env.example ships dev defaults with no production hints

Symptom

The official .env.example ships with:

APP_ENV=local
APP_DEBUG=true
LOG_LEVEL=debug
SESSION_ENCRYPT=false
# SESSION_SECURE_COOKIE absent

Why it matters

Copying .env.example to .env is the standard Laravel step. A team that carries the file to production without auditing each line ships:

  • APP_DEBUG=true → exception pages expose stack traces, environment variables, and DB credentials. That's a blueprint handed to an attacker.
  • LOG_LEVEL=debug → request detail, sometimes credentials and PII, written to log files continuously.
  • SESSION_ENCRYPT=false + no SESSION_SECURE_COOKIE → session cookies are plaintext and can be sent over HTTP.

This isn't a "misconfiguration" — it's a structural gap: the example file was never written with production in mind.

Fix

Annotate the relevant keys with inline production-override comments. No runtime change — pure documentation.

APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost
# Production overrides:
#   APP_ENV=production
#   APP_DEBUG=false
#   APP_URL must be the https:// URL that TLS terminates at.

The same treatment is applied to LOG_LEVEL and the SESSION_* keys.

C. config/app.php hardcodes the timezone

Symptom

Line 68 of config/app.php:

'timezone' => 'UTC',

Why it matters

This is a footgun rather than a vulnerability. Setting APP_TIMEZONE=Asia/Tokyo in .env has no effect because config/app.php doesn't go through env(). Laravel's current skeleton already uses env('APP_TIMEZONE', 'UTC'); the starter kit hasn't caught up.

A wrong timezone skews article publish times, log timestamps, and scheduled jobs. You end up debugging a mysterious 9-hour offset after deploy.

Fix

One line.

-    'timezone' => 'UTC',
+    'timezone' => env('APP_TIMEZONE', 'UTC'),

APP_TIMEZONE is also documented in .env.example.

D. No security response headers in the middleware stack

Symptom

The web middleware stack in bootstrap/app.php registers nothing that emits security headers (the exact starting state differs per kit — see the cluster articles). As a result every one of these is absent:

  • Content-Security-Policy
  • Strict-Transport-Security (HSTS)
  • X-Content-Type-Options
  • X-Frame-Options
  • Referrer-Policy
  • Permissions-Policy

Why it matters

These are defense-in-depth layers. None is "this alone makes you safe," but missing them leaves these open:

  • No CSP → no browser-side backstop against the inline / injected scripts an XSS uses.
  • No HSTS → on first visit or via an http:// link, a man-in-the-middle can downgrade to HTTP (SSL stripping).
  • No X-Content-Type-Options: nosniff → the browser may "sniff" a content type and execute a plain text file as a script.
  • No X-Frame-Options → any site can iframe yours and run a clickjacking attack.

"The reverse proxy adds them" is a common assumption, but a Laravel app served directly by nginx + php-fpm has nobody adding them. The app should carry safe defaults itself.

Fix

A new app/Http/Middleware/SetSecurityHeaders.php, appended to the web stack.

  • Always on: X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy: camera=(), microphone=(), geolocation=()
  • Production only: Strict-Transport-Security: max-age=31536000; includeSubDomains and Content-Security-Policy

HSTS and CSP are production-gated because (1) HSTS pinned to localhost can't be undone locally, and (2) CSP collides with dev servers like Vite HMR.

The CSP body depends on the frontend architecture. Inertia (React/Vue) serializes initial props into an inline script, so 'unsafe-inline' needs care; Livewire doesn't need it. Details and the nonce-based migration are in the cluster articles and in the Livewire CSP nonce article.

E. No HTTPS scheme enforcement helper

Symptom

Repo-wide search for forceScheme / forceHttps returns nothing. No permanent HTTP→HTTPS redirect, no scheme pinning.

Why it matters

In production, TLS is usually terminated at a reverse proxy (nginx / load balancer / CDN) and forwarded to the backend as http://. Laravel then believes it runs over HTTP, and every URL from route() / asset() comes out as http://.

Consequences:

  • Links and asset URLs in HTML are http:// → the browser flags or blocks mixed content.
  • Redirect targets are http:// → the proxy bounces them back to https://, sometimes into a redirect loop.
  • Absolute URLs that leave the app (email links, OGP, sitemap) go out as http://.

Fix

Three lines in AppServiceProvider::configureDefaults.

if (app()->isProduction()) {
    URL::forceScheme('https');
}

If the proxy sends a correct X-Forwarded-Proto, trusted-proxy config can also solve this — but the trusted-proxy CIDR is infrastructure-specific, so this keeps to the minimal scheme-pinning fix.

F. Password rules (already handled upstream)

The initial pass suspected Password::default() resolved to the framework default (8 chars, no complexity). Re-auditing showed that was wrong.

AppServiceProvider::configureDefaults already installs:

Password::defaults(fn (): ?Password => app()->isProduction()
    ? Password::min(12)->mixedCase()->letters()->numbers()->symbols()->uncompromised()
    : null,
);

Registering a closure via Password::defaults() (plural, the setter) is what Password::default() (singular, the getter) resolves. In production: min 12, mixed case, letters + numbers + symbols, plus a Have I Been Pwned check. No change needed.

Recorded as a reminder: don't declare a finding "unaddressed" without checking whether the official sample already handles it.

G. Login throttle keys on email + IP only

Symptom

FortifyServiceProvider::configureRateLimiting has a single login limit:

RateLimiter::for('login', function (Request $request) {
    $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
    return Limit::perMinute(5)->by($throttleKey);
});

Why it matters

A "5 / minute" limit keyed on email + IP stops naive brute force from a single IP. But because the key includes the IP, an attacker rotating IPs (proxy pool, botnet) changes the key every attempt and effectively nullifies the limit. Thousands of attempts per hour against one account pass without ever tripping it.

This limit only stops an attacker who can't change IPs. Real credential stuffing changes IPs.

Fix

RateLimiter::for can return an array of limits — all evaluated, any one tripping yields a 429. Use it for a two-layer guard.

return [
    // Burst guard: 5 / minute per email + IP (unchanged behavior)
    Limit::perMinute(5)->by($email.'|'.$ip),
    // Account-level cumulative guard: 20 / hour per email, IP-independent
    Limit::perHour(20)->by('login-account|'.$email),
];

The second layer has no IP in the key, so no amount of IP rotation gets past 20 attempts/hour per account. A legitimate user almost never fails a password 20 times in an hour, so false positives stay low.

H. 2FA is available but not policy-enforced (a design call)

Fortify's two-factor feature is enabled, with UI, a settings page, and tests. Users can opt in.

What's missing:

  • A policy helper to require 2FA for admin accounts
  • A reminder / nag banner for users who haven't enabled it
  • Role-aware enforcement

This is a design decision, not a defect. Whether it belongs in a starter kit or in each product is case-by-case. It's left out of CodeLift's improvement commits and recorded here as context.

I. No separated logging channel for auth events

Symptom

config/logging.php ships the default stacksingle layout. App logs, DB query logs, and auth events all interleave chronologically in one laravel.log.

Why it matters

Not a vulnerability directly, but it costs incident-response speed. To answer "were there suspicious login attempts against this account last week," auth events buried among everything else force a grep-based investigation. Retention as an audit log can't be tuned separately either.

Fix

Two files.

  1. Add an auth channel to config/logging.php (daily driver, 90-day retention, env-tunable via LOG_AUTH_LEVEL / LOG_AUTH_DAYS).
  2. Add app/Listeners/AuthActivitySubscriber.php, registered via Event::subscribe in AppServiceProvider::boot.

The subscriber listens to:

  • Illuminate\Auth\Events\Registered / Login / Logout / Failed / PasswordReset
  • Laravel\Fortify\Events\TwoFactorAuthenticationEnabled / TwoFactorAuthenticationDisabled

Each record carries user_id / email / ip / user_agent as structured context. Output lands in storage/logs/auth-YYYY-MM-DD.log, so audits tail / grep cleanly and retention is set independently.

Summary — effect of the shared backend hardening

Dimension Official Improved
Production hints in .env.example None Inline comments
APP_TIMEZONE takes effect No (hardcoded) Yes
Baseline security headers None 4 always-on + HSTS / CSP in production
HTTPS scheme forcing None Production-only URL::forceScheme('https')
Login rate limit Email + IP, one layer Email + IP and email-only
Auth event logging Mixed in default log Dedicated auth daily channel (90-day)

All six are applied with identical code across the React / Vue / Livewire forks. For each fork's actual commits, test results, and framework-specific work, see the cluster articles linked at the top.

There's also a side-by-side production-readiness comparison of the three:

License

  • Upstream: MIT (Laravel LLC and contributors)
  • Improvement forks: MIT (CodeLift / JIT Inc.)

Findings reflect the verification date; upstream may change.

Related articles