laravel-security — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited laravel-security (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
@rules/security/backend.md and @rules/security/frontend.md@rules/php/core-standards.mdc — final classes, declare(strict_types=1), typed signatures@rules/laravel/laravel.mdc, @rules/laravel/architecture.mdc, @rules/laravel/filament.mdc, @rules/laravel/livewire.mdcSecure-by-default building blocks for security-sensitive Laravel work. Use the matching section, copy the minimal snippet, and verify against the checklist. For an audit of existing code use @skills/security-review/SKILL.md.
// config/app.php
'debug' => (bool) env('APP_DEBUG', false), // CRITICAL: never true in production
'key' => env('APP_KEY'), // php artisan key:generate
// config/session.php
'secure' => env('SESSION_SECURE_COOKIE', true),
'http_only' => true,
'same_site' => 'lax',Validate required config at boot and fail fast:
// AppServiceProvider::boot()
foreach (['app.key', 'database.connections.mysql.database'] as $key) {
if (empty(config($key))) {
throw new RuntimeException("Missing required config key: {$key}");
}
}HTTPS and trusted proxies:
if (app()->environment('production')) {
URL::forceScheme('https');
}
// Use specific CIDR ranges, never '*' (X-Forwarded-* spoofing)
'trusted_proxies' => ['10.0.0.0/8', '172.16.0.0/12'],Keep .env out of version control (.gitignore ships with .env); ship .env.example with empty placeholders.
// config/sanctum.php
'expiration' => 60 * 24, // minutes; null = never
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
$token = $user->createToken('api', ['posts:read', 'posts:write'])->plainTextToken;
Route::middleware('auth:sanctum')->group(function () {
Route::get('/posts', [PostController::class, 'index'])->middleware('abilities:posts:read');
Route::post('/posts', [PostController::class, 'store'])->middleware('abilities:posts:write');
});// config/hashing.php — bcrypt rounds >= 12, or Argon2id
'bcrypt' => ['rounds' => env('BCRYPT_ROUNDS', 12)],
// FormRequest rules
'password' => [
'required', 'confirmed',
Password::min(12)->letters()->mixedCase()->numbers()->symbols()->uncompromised(),
],// config/session.php — 'driver' => 'database' or 'redis' (avoid 'file' in prod)
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate(); // CRITICAL: prevents session fixation
return redirect()->intended('/dashboard');
}
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}// AppServiceProvider::boot()
Gate::define('update-post', fn (User $user, Post $post): bool => $user->id === $post->user_id);
Gate::before(function (User $user, string $ability): ?bool {
return $user->role === 'super-admin' ? true : null; // null = fall through
});
// Controller
Gate::authorize('update-post', $post);final class PostPolicy
{
public function view(?User $user, Post $post): bool
{
return $post->is_published || ($user && $user->id === $post->user_id);
}
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
}
// Controller: $this->authorize('update', $post);
// Blade: @can('update', $post) ... @endcanLaravel 11 auto-discovers policies by naming convention; register explicitly only when names differ.
Route::put('/posts/{post}', [PostController::class, 'update'])->middleware('can:update,post');
Route::middleware(['auth', 'role:admin'])->group(function () {
Route::get('/admin', [AdminController::class, 'index']);
});For Filament, enforce access via policies and canAccessPanel(); for Livewire, re-check authorization inside actions — a mounted component is not an authorization boundary (@rules/laravel/filament.mdc, @rules/laravel/livewire.mdc).
final class User extends Authenticatable
{
protected $fillable = ['name', 'email', 'phone', 'avatar'];
// NEVER list 'role', 'is_admin'; NEVER use $guarded = []
}
User::create($request->validated()); // GOOD: validated fields only
// User::create($request->all()); // VULNERABLEUser::where('email', $userInput)->first(); // parameterized
User::whereRaw('email = ?', [$userInput])->first(); // parameterized
DB::select('SELECT * FROM users WHERE email = ?', [$input]); // parameterized
// VULNERABLE — never interpolate user input:
// User::whereRaw("email = '{$userInput}'")->first();
// User::orderByRaw($userInput); DB::statement("... '{$userInput}'");final class User extends Authenticatable
{
protected $casts = [
'is_admin' => 'boolean',
'settings' => 'array',
'metadata' => 'encrypted:array', // Laravel 11 encrypted cast
'password' => 'hashed', // auto-hash on set
];
protected $hidden = ['password', 'remember_token', 'two_factor_secret'];
}CSRF is on by default for the web group. State-changing forms need @csrf:
<form method="POST" action="/posts">@csrf ...</form>Exclude only specific signature-verified webhooks — never blanket api/* (stateful Sanctum needs CSRF):
// bootstrap/app.php — $middleware->validateCsrfTokens(except: ['stripe/*'])For JS, send the token header (Axios ships preconfigured in Laravel):
<meta name="csrf-token" content="{{ csrf_token() }}">{{ $userInput }} {{-- SAFE: auto-escaped --}}
{!! $userInput !!} {{-- DANGEROUS: raw, never with user input --}}
{!! $trustedHtml !!} {{-- only for content you fully control --}}
<script>
const user = @js($user); {{-- escaped for JS context --}}
const cfg = @json($config);
</script>When user HTML must survive, purify with an allowlist before storing/output:
// composer require ezyang/htmlpurifier
$config = \HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,b,i,a[href],ul,ol,li,br');
$config->set('URI.AllowedSchemes', ['http', 'https', 'mailto']);
$clean = (new \HTMLPurifier($config))->purify($dirty);In Alpine, prefer x-text over x-html; only use x-html on sanitized content. Add security headers via middleware:
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Content-Security-Policy',
"default-src 'self'; frame-ancestors 'none'");Always validate through a FormRequest; never persist $request->all():
final class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create', Post::class) ?? false;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string', 'max:10000'],
'tags' => ['array'],
'tags.*' => ['integer', 'exists:tags,id'],
];
}
}Keep validation messages generic — never leak which auth factor failed, whether a record exists, or framework internals (@rules/security/backend.md).
// AppServiceProvider::boot()
RateLimiter::for('api', fn (Request $r) =>
Limit::perMinute(60)->by($r->user()?->id ?: $r->ip()));
RateLimiter::for('auth', fn (Request $r) =>
Limit::perMinute(5)->by($r->ip()));
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:auth');// config/cors.php
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', '')), // explicit allowlist
'supports_credentials' => true, // required for Sanctum SPA auth
// NEVER ['*'] when credentials are supportedpublic function rules(): array
{
return [
'document' => ['required', 'file', 'mimes:pdf,doc,docx', 'max:10240',
'extensions:pdf,doc,docx'],
'avatar' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048',
'dimensions:max_width=2000,max_height=2000'],
];
}Store sensitive files off the public disk and serve through an authorized, time-limited URL:
$path = $request->file('document')->store('documents', 'local'); // not 'public'
public function download(Request $request, string $path): RedirectResponse
{
$this->authorize('download', $path);
return redirect(Storage::temporaryUrl($path, now()->addMinutes(15)));
}composer audit # run in CI; fail the build on advisories
# keep composer.lock committed; run composer update deliberately, never in CIRead every secret from env()/config(); validate presence at boot. For production use a secret manager rather than a deployed .env.
// Encrypt sensitive payloads on the wire
final class ProcessPaymentJob implements ShouldQueue, ShouldBeEncrypted
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private readonly string $paymentIntentId,
private readonly string $cardFingerprint,
) {}
public function handle(): void { /* ... */ }
public function retryUntil(): \Carbon\CarbonInterface
{
return now()->addMinutes(5);
}
}// config/logging.php — dedicated 'security' channel
final class SecurityLogger
{
public static function log(string $event, array $context = []): void
{
Log::channel('security')->warning($event, array_merge([
'user_id' => Auth::id(),
'ip' => request()->ip(),
'url' => request()->fullUrl(),
], $context));
}
}
SecurityLogger::log('failed_login_attempt', ['email' => $email]);
SecurityLogger::log('role_change', ['target_user' => $targetId, 'new_role' => 'admin']);| Check | Description |
|---|---|
APP_DEBUG=false | Never run with debug enabled in production |
APP_KEY set | Always run php artisan key:generate |
| HTTPS enforced | Force HTTPS in production via middleware or proxy |
$fillable whitelisted | Never use $guarded = [] |
| CSRF active | @csrf on all state-changing forms |
| Sanctum scopes | Token abilities enforced per route |
| Rate limiting | Throttle API and auth endpoints |
| Input validation | FormRequest with specific rules, never $request->all() |
| File upload restrictions | Validate MIME, extension, size, dimensions |
composer audit in CI | Check dependencies for known vulnerabilities |
| Password hashing | Laravel bcrypt/Argon2, 'password' => 'hashed' cast |
| Session regeneration | Call $request->session()->regenerate() on login |
| Security headers | CSP, X-Frame-Options, X-Content-Type-Options |
| Security event logging | Audit auth failures, role changes, suspicious activity |
.env not committed | Verify .gitignore includes .env |
When auditing an existing Laravel application (instead of building new features), use @skills/laravel-security/references/audit-workflow.md. It covers the 7 audit areas — Authorization/IDOR/BOLA, Authentication, Validation, XSS, File upload, Secrets/configuration, Dependencies — with severity mapping (Critical/High/Medium/Low/Info → CR scale Critical/Moderate/Minor), Grep patterns, and a required regression-test sketch per confirmed finding. The building blocks in this file (Production Configuration, Authentication, Authorization, Eloquent Security, CSRF, XSS Prevention, Input Validation, File Upload Security, Secrets and Dependencies) are the reference fixes the audit workflow links back to.
@skills/security-review/SKILL.md — review workflow for existing code@skills/security-threat-analysis/SKILL.md — remediate a referenced advisory/CVE@skills/test-driven-development/SKILL.md — drive the implementation test-first~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.