
Laravel Owasp Security
- 339 installs
- 60 repo stars
- Updated May 16, 2026
- asyrafhussin/agent-skills
Harden Laravel apps against OWASP Top 10 risks before release, covering auth, input validation, CSRF, SQLi, XSS, and secure configuration checks.
About
Laravel OWASP security skill for agent-assisted PHP apps: maps OWASP risks to Laravel middleware, Eloquent queries, validation, sessions, and deployment settings so APIs and ecommerce backends ship with fewer exploitable flaws.
- OWASP Top 10 mapping
- Laravel auth hardening
- Input and CSRF protection
- Secure config defaults
- Pre-release security checklist
Laravel Owasp Security by the numbers
- 339 all-time installs (skills.sh)
- Ranked #593 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/asyrafhussin/agent-skills --skill laravel-owasp-securityAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 339 |
|---|---|
| repo stars | ★ 60 |
| Last updated | May 16, 2026 |
| Repository | asyrafhussin/agent-skills ↗ |
What it does
Harden Laravel apps against OWASP Top 10 risks before release, covering auth, input validation, CSRF, SQLi, XSS, and secure configuration checks.
Files
Laravel OWASP Security
Dual-purpose security skill for Laravel 13 + React/Inertia.js applications. Run a full OWASP Top 10 audit against a codebase, or use as a secure coding reference when building features.
How to Audit
Step 1: Detect Stack
Check if the project uses React + Inertia.js by looking for:
app/Http/Middleware/HandleInertiaRequests.phpexistsresources/js/contains.tsxor.jsxfilesinertiajs/inertia-laravelincomposer.json@inertiajs/reactinpackage.json
If detected, state at the top of the report:
"React + Inertia.js detected — Laravel OWASP checklist AND React/Inertia security checks will both be applied."
If not detected, state:
"No React/Inertia.js detected — applying Laravel OWASP checklist only."
Step 2: Determine Scope
- If arguments provided (
$ARGUMENTS): review only those files or features - If no arguments: review the entire codebase
Step 3: Run Checklist
Work through every item below. For each, output:
- PASS — brief confirmation of what was verified
- FAIL — exact
file:line, a description of the vulnerability (do NOT reproduce any code, values, API keys, tokens, or .env contents from the file), and a fix recommendation - N/A — if the check does not apply to this project
---
OWASP Top 10 Checklist
1. Broken Access Control (A01:2021)
- [ ] Middleware protects all route groups by role (
auth,role:admin, etc.) - [ ] Resource queries scoped to authenticated user —
->where('user_id', auth()->id()) - [ ] No direct object reference without ownership check
- [ ] Gates and Policies used to authorize resource access
- [ ] Frontend role checks are mirrored server-side — never rely on React UI checks alone
2. Cryptographic Failures (A02:2021)
- [ ] Passwords hashed with
Hash::make()or'hashed'Eloquent cast — never stored as plaintext - [ ] No MD5 or SHA1 used for password hashing
- [ ] Sensitive fields (API keys, secrets) encrypted with
Crypt::encryptString()or'encrypted'Eloquent cast - [ ]
APP_KEYis long, random, and unique per environment - [ ] Signed URLs (
URL::signedRoute()) used for sensitive one-time actions (password reset, email verify)
3. Injection (A03:2021)
SQL & Mass Assignment:
- [ ] No string concatenation in
whereRaw(),selectRaw(),orderByRaw()— use?bindings - [ ] Column names never derived from user input without a whitelist
- [ ] No
$request->all()passed directly tocreate(),fill(), orupdate() - [ ] No
forceFill()orforceCreate()with unvalidated user input - [ ] Models define
$fillableexplicitly — not$guarded = [] - [ ] Controllers use
$request->validated()for mass operations
XSS — Blade & React:
- [ ] No
{!! $userInput !!}in Blade templates with untrusted data - [ ]
{{ }}used for all user-supplied Blade output - [ ] No
dangerouslySetInnerHTMLin React withoutDOMPurify.sanitize()first - [ ]
hrefandsrcattributes not set from unvalidated user input - [ ] No
eval(),new Function(), orsetTimeout(string)with user-controlled strings - [ ] External CDN scripts use Subresource Integrity (
integrity="sha384-...")
4. Insecure Design (A04:2021)
- [ ] Business logic enforced server-side — prices, totals, and discounts never trusted from client input
- [ ] Sensitive operations require secondary confirmation (e.g. password re-entry for account deletion)
- [ ] No mass action endpoints without per-item authorization check
- [ ] Admin-only features isolated behind separate middleware — not just hidden in the UI
- [ ] Payment amounts and enrollment states calculated server-side, not passed as form inputs
5. Security Misconfiguration (A05:2021)
- [ ]
APP_DEBUG=falsein production - [ ]
.envis in.gitignoreand never committed - [ ] Database uses a restricted user — not root/admin — in production
- [ ]
storage/andbootstrap/cache/have correct permissions (not world-writable) - [ ]
APP_KEYis set and unique per environment - [ ] CORS
allowed_originsis not['*']for authenticated API routes
6. Vulnerable & Outdated Components (A06:2021)
- [ ]
composer auditpasses with no known CVEs - [ ]
npm auditpasses with no known CVEs - [ ] Laravel framework is on a supported version
7. Identification & Authentication Failures (A07:2021)
Auth:
- [ ] Using Laravel Breeze, Fortify, or Jetstream — not custom-rolled auth
- [ ] Passwords hashed with bcrypt or argon2 (Laravel default)
- [ ] Login route rate limited —
throttlemiddleware orRateLimiterinLoginRequest - [ ] Password reset and email verification routes rate limited
- [ ] Payment and sensitive action routes have appropriate rate limits
- [ ]
session()->regenerate()called after successful login
Cookie & Session:
- [ ]
http_only = trueinconfig/session.php - [ ]
same_site = laxorstrictinconfig/session.php - [ ]
secure = trueornull(auto for HTTPS) inconfig/session.php - [ ]
lifetimeis a reasonable value (15–30 min recommended for most apps) - [ ]
domain = nullunless subdomains are needed - [ ]
EncryptCookiesmiddleware is in the web group
8. Software & Data Integrity Failures (A08:2021)
CSRF:
- [ ]
VerifyCsrfTokenmiddleware active in the web group - [ ] Only stateless routes (webhooks, external callbacks) are excluded from CSRF
- [ ]
@csrfdirective used in all non-Inertia POST forms - [ ] Excluded routes in
validateCsrfTokens(except: [...])are justified
Deserialization:
- [ ] No
unserialize($request->input(...)) - [ ] No
eval($request->input(...)) - [ ] No
extract($request->all())
9. Security Logging & Monitoring Failures (A09:2021)
- [ ] Failed login attempts logged with IP and identifier
- [ ] Payment failures and exceptions logged
- [ ] Log entries do not contain raw passwords or secrets
- [ ] Monitoring in place (Laravel Telescope, Sentry, or similar)
10. Server-Side Request Forgery — SSRF (A10:2021)
- [ ] No
Http::get($request->input('url'))with unvalidated URLs - [ ] User-supplied URLs validated against an allowlist or scheme check
- [ ] Internal network addresses blocked from user-supplied URLs
---
Additional Checks
Not part of the OWASP Top 10 but critical for Laravel applications.
Command Injection & Dangerous Functions
- [ ] No
exec(),shell_exec(),system(),passthru()with user input - [ ] No open redirects — no
redirect($request->input('url'))with unvalidated URLs - [ ] File uploads validate
mimes:,max:— filenames never derived from raw user input
Security Headers
- [ ]
Content-Security-Policyset — with nonces (Vite::useCspNonce()) if possible - [ ]
X-Frame-Optionsset - [ ]
X-Content-Type-Optionsset - [ ]
Strict-Transport-Securityset for HTTPS - [ ]
Referrer-Policyset - [ ]
Permissions-Policyset
---
React + Inertia.js Checks
Only run if React + Inertia.js detected in Step 1.
R1. XSS in React Components
- [ ] No
dangerouslySetInnerHTML={{ __html: userInput }}withoutDOMPurify.sanitize()first - [ ]
hrefandsrcattributes not set from unvalidated user input —javascript:URLs execute scripts - [ ] No
eval(),new Function(), orsetTimeout(string)with user-controlled strings - [ ] Links from user input validate scheme (
https://orhttp://only)
R2. Inertia.js Data Exposure (Critical)
- [ ]
HandleInertiaRequests::share()does NOT expose passwords, tokens, or internal-only flags - [ ] Controllers use
->only([...])or API Resources — not raw modeltoArray() - [ ] All Inertia props are treated as public — visible in
data-pageHTML attribute on initial load - [ ] Payment secret keys and admin-only credentials are never passed as Inertia props
- [ ] Inertia v2 History Encryption enabled for pages with sensitive data
R3. CSRF in Inertia.js
- [ ] Inertia
X-XSRF-TOKENheader not disabled - [ ] Custom
fetchoraxioscalls include CSRF token manually if bypassing Inertia's router - [ ] Webhook/callback routes are the ONLY CSRF-excluded routes
R4. Authentication State in React
- [ ]
auth.userInertia prop excludes password hash, remember tokens, and 2FA secrets - [ ] Role/permission checks enforced server-side — React checks are UI-only
- [ ]
auth.usercontains only fields the UI actually needs
R5. Sensitive Data in Browser
- [ ] No API keys or secrets hardcoded in React components or TypeScript files
- [ ] No sensitive data in
localStorageorsessionStorage— use HttpOnly cookies - [ ]
VITE_*env vars contain no secrets — they are public by design
R6. Dependency Security
- [ ]
npm auditpasses with no high/critical CVEs in React or Inertia packages - [ ] React is on a supported version
- [ ] Third-party component libraries reviewed for known CVEs
---
Output Format
Structure the audit report as:
## Laravel OWASP Security Audit Report
> React + Inertia.js detected — Laravel OWASP checklist AND React/Inertia security checks will both be applied.
### 1. Broken Access Control (A01:2021)
- **PASS** `app/Http/Middleware/RoleMiddleware.php` — role middleware applied to all route groups
- **FAIL** `app/Http/Controllers/PaymentController.php:42` — Payment model fetched without ownership check (direct object reference exposure). Fix: scope the query to the authenticated user.
[Continue for all 10 OWASP checks + Additional Checks + R1–R6 React/Inertia checks]
---
## Summary
### Critical Issues (fix immediately)
1. ...
### Warnings (fix soon)
1. ...
### Passed
X checks passed.
### Recommended Commands
composer audit
npm audit---
When to Apply for Guidance
Reference the rule files when:
- Implementing authentication or password handling
- Building payment or webhook integrations
- Writing file upload or download logic
- Designing admin or role-based access control
- Building API endpoints with user-supplied input
- Using
dangerouslySetInnerHTMLin React components - Passing data from Laravel controllers to Inertia props
Rule Categories by Priority
| Priority | Category | Impact | Rule File |
|---|---|---|---|
| 1 | Broken Access Control | CRITICAL | sec-broken-access-control |
| 2 | Cryptographic Failures | CRITICAL | sec-cryptographic-failures |
| 3 | Injection Prevention | CRITICAL | sec-injection-prevention |
| 4 | XSS & React/Inertia | HIGH | sec-xss-react-inertia |
| 5 | CSRF Protection | HIGH | sec-csrf-protection |
| 6 | Security Misconfiguration | HIGH | sec-security-misconfiguration |
| 7 | Authentication & Rate Limiting | HIGH | sec-authentication-rate-limiting |
| 8 | Inertia Data Exposure | HIGH | sec-inertia-data-exposure |
Quick Reference
1. Broken Access Control (CRITICAL)
sec-broken-access-control— Middleware, ownership checks, policies, scoped queries
2. Cryptographic Failures (CRITICAL)
sec-cryptographic-failures— Password hashing, encrypted casts, signed URLs
3. Injection Prevention (CRITICAL)
sec-injection-prevention— SQL injection, mass assignment, raw query bindings
4. XSS & React/Inertia (HIGH)
sec-xss-react-inertia— dangerouslySetInnerHTML, DOMPurify, href/src validation
5. CSRF Protection (HIGH)
sec-csrf-protection— VerifyCsrfToken, webhook exclusions, Inertia CSRF
6. Security Misconfiguration (HIGH)
sec-security-misconfiguration— APP_DEBUG, APP_KEY, security headers, CORS
7. Authentication & Rate Limiting (HIGH)
sec-authentication-rate-limiting— Throttle, session regeneration, brute force prevention
8. Inertia Data Exposure (HIGH)
sec-inertia-data-exposure— data-page attribute exposure, secret props, API Resources
How to Use
Read individual rule files for detailed explanations and code examples:
rules/sec-broken-access-control.md
rules/sec-cryptographic-failures.md
rules/sec-injection-prevention.md
rules/sec-xss-react-inertia.md
rules/sec-csrf-protection.md
rules/sec-security-misconfiguration.md
rules/sec-authentication-rate-limiting.md
rules/sec-inertia-data-exposure.mdEach rule file contains:
- YAML frontmatter with metadata (title, impact, tags)
- Why it matters in Laravel/React context
- Incorrect code example with explanation
- Correct code example with fix
- Laravel 13 and PHP 8.3+ specific context
Full Compiled Document
For the complete guide with all rules expanded: AGENTS.md
Laravel OWASP Security — Full Compiled Reference
OWASP Top 10 security audit checklist and secure coding reference for Laravel 13 + React/Inertia.js applications.
Version: 1.0.3 | Laravel: 13.x | PHP: 8.3+ | Author: AsyrafHussin
---
How to Run an Audit
Step 1: Detect Stack
Check if the project uses React + Inertia.js:
app/Http/Middleware/HandleInertiaRequests.phpexistsresources/js/contains.tsxor.jsxfilesinertiajs/inertia-laravelincomposer.json@inertiajs/reactinpackage.json
State at the top of report:
- Detected: "React + Inertia.js detected — Laravel OWASP checklist AND React/Inertia security checks will both be applied."
- Not detected: "No React/Inertia.js detected — applying Laravel OWASP checklist only."
Step 2: Scope
- Arguments provided: review only those files/features
- No arguments: review entire codebase
Step 3: Output Format
- PASS
file:line— brief confirmation - FAIL
file:line— description of the vulnerability (do NOT reproduce any code, values, API keys, tokens, or .env contents from the file) + fix recommendation - N/A — not applicable to this project
---
Section 1: Broken Access Control (A01:2021) — CRITICAL
The most common critical vulnerability. Attackers access other users' records, perform admin actions, or escalate privileges by manipulating IDs or bypassing role checks.
Checklist
- [ ] Middleware protects all route groups by role (
auth,role:admin, etc.) - [ ] Resource queries scoped to authenticated user —
->where('user_id', auth()->id()) - [ ] No direct object reference without ownership check
- [ ] Gates and Policies used to authorize resource access
- [ ] Frontend role checks are mirrored server-side — never rely on React UI checks alone
Incorrect
// ❌ No ownership check — any user can view any payment by changing the ID
$payment = Payment::find($id);
// ❌ Admin routes without middleware
Route::prefix('admin')->group(function () {
Route::get('/users', [UserController::class, 'index']);
});Correct
// ✅ Always scope to authenticated user
$payment = Payment::where('user_id', auth()->id())->findOrFail($id);
// ✅ Role middleware on all admin routes
Route::middleware(['auth', 'role:admin'])->prefix('admin')->group(function () {
Route::resource('users', UserController::class);
});
// ✅ Policy-based authorization
$this->authorize('view', $payment);---
Section 2: Cryptographic Failures (A02:2021) — CRITICAL
Weak or missing cryptography exposes passwords, API keys, and sensitive data on any breach.
Checklist
- [ ] Passwords hashed with
Hash::make()or'hashed'Eloquent cast - [ ] No MD5 or SHA1 for password hashing
- [ ] Sensitive fields encrypted with
Crypt::encryptString()or'encrypted'cast - [ ]
APP_KEYis long, random, and unique per environment - [ ] Signed URLs used for password reset, email verification
Incorrect
// ❌ Plaintext password
User::create(['password' => $request->password]);
// ❌ Weak hashing
$hash = md5($request->password);
// ❌ API key in plaintext DB column
Setting::set('toyyibpay_secret_key', $request->secret_key);Correct
// ✅ Hashed cast on User model
protected $casts = ['password' => 'hashed'];
// ✅ Encrypted cast for sensitive DB columns
protected $casts = ['value' => 'encrypted'];
// ✅ Signed URL for email verification
Route::get('/email/verify/{id}/{hash}', [VerifyEmailController::class, '__invoke'])
->middleware(['auth', 'signed', 'throttle:6,1'])
->name('verification.verify');---
Section 3: Injection (A03:2021) — CRITICAL
SQL injection allows database exfiltration. Mass assignment allows setting fields like is_admin. XSS (also A03) allows script execution via user-supplied content.
Checklist
SQL & Mass Assignment:
- [ ] No string concatenation in
whereRaw(),selectRaw(),orderByRaw() - [ ] Column names from user input whitelist-validated
- [ ] No
$request->all()increate(),fill(),update() - [ ] No
forceFill()with unvalidated input - [ ] Models define
$fillableexplicitly - [ ] Controllers use
$request->validated()
XSS — Blade & React:
- [ ] No
{!! $userInput !!}in Blade with untrusted data - [ ] No
dangerouslySetInnerHTMLwithoutDOMPurify.sanitize() - [ ]
href/srcnot set from unvalidated user input - [ ] No
eval()ornew Function()with user-controlled strings
Incorrect
// ❌ SQL injectable
$classes = DB::select("SELECT * FROM classes ORDER BY {$request->sort}");
// ❌ Mass assignment
User::create($request->all());
// ❌ $guarded = []
protected $guarded = [];// ❌ Raw HTML without sanitization — stored XSS
<div dangerouslySetInnerHTML={{ __html: sessionNote.notes }} />
// ❌ Unvalidated href — javascript: URLs execute on click
<a href={user.website}>Link</a>Correct
// ✅ Parameterized bindings
$classes = Class::whereRaw('name LIKE ?', ['%' . $request->search . '%'])->get();
// ✅ Whitelist for dynamic columns
$column = in_array($request->column, ['name', 'price', 'created_at'])
? $request->column : 'created_at';
// ✅ Use validated() only
Post::create([...$request->validated(), 'user_id' => auth()->id()]);
// ✅ Explicit $fillable
protected $fillable = ['title', 'body', 'category_id'];import DOMPurify from 'dompurify';
// ✅ Always sanitize
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(sessionNote.notes) }} />
// ✅ Validate URL scheme
const safeUrl = url.startsWith('https://') || url.startsWith('http://') ? url : '#';
<a href={safeUrl}>Link</a>---
Section 4: Insecure Design (A04:2021) — HIGH
Missing server-side business logic controls allow clients to manipulate prices, bypass payments, or perform unauthorized bulk operations.
Checklist
- [ ] Business logic enforced server-side — prices, totals, and discounts never trusted from client input
- [ ] Sensitive operations require secondary confirmation (e.g. password re-entry for account deletion)
- [ ] No mass action endpoints without per-item authorization check
- [ ] Admin-only features isolated behind separate middleware — not just hidden in the UI
- [ ] Payment amounts and enrollment states calculated server-side, not passed as form inputs
Incorrect
// ❌ Trusting client-supplied price
$amount = $request->input('amount');
Payment::create(['amount' => $amount, 'user_id' => auth()->id()]);
// ❌ Admin check only in frontend
// React: {isAdmin && <DeleteButton />} — no server-side gate
Route::delete('/users/{user}', [UserController::class, 'destroy']); // No middlewareCorrect
// ✅ Always calculate server-side
$registration = Registration::where('user_id', auth()->id())->findOrFail($id);
$amount = $registration->remaining_balance; // Derived from DB, never from request
// ✅ Gate on every destructive route
Route::middleware(['auth', 'role:admin'])
->delete('/users/{user}', [UserController::class, 'destroy']);---
Section 5: Security Misconfiguration (A05:2021) — HIGH
Misconfigured environments expose stack traces and leave browser protections disabled.
Checklist
- [ ]
APP_DEBUG=falsein production - [ ]
.envin.gitignoreand never committed - [ ] Database uses a restricted user — not root/admin
- [ ]
APP_KEYset and unique per environment - [ ] CORS
allowed_originsnot['*']for authenticated routes - [ ] Security headers middleware active (CSP, X-Frame-Options, HSTS, etc.)
Correct
// .env (production)
APP_ENV=production
APP_DEBUG=false
DB_USERNAME=app_user // Not root
// config/cors.php
'allowed_origins' => ['https://yourdomain.com'],
// SecurityHeaders middleware — CSP with nonce
$nonce = Vite::useCspNonce();
$response->headers->set('Content-Security-Policy', implode('; ', [
"default-src 'self'",
"script-src 'self' 'nonce-{$nonce}'",
"style-src 'self' 'unsafe-inline' https://fonts.bunny.net",
"object-src 'none'",
"frame-ancestors 'none'",
]));---
Section 6: Vulnerable & Outdated Components (A06:2021) — MEDIUM
Checklist
- [ ]
composer auditpasses with no known CVEs - [ ]
npm auditpasses with no known CVEs - [ ] Laravel framework on supported version
composer audit
npm audit---
Section 7: Identification & Authentication Failures (A07:2021) — HIGH
Brute force and session fixation are prevented by throttling and session regeneration. Cookie misconfiguration allows session theft.
Checklist
Auth:
- [ ] Login throttled via
RateLimiterinLoginRequest - [ ] Password reset and email verify routes throttled
- [ ] Payment routes throttled (
throttle:10,1) - [ ]
session()->regenerate()called after login - [ ]
session()->invalidate()called on logout - [ ]
SESSION_LIFETIMEset to 30 minutes or less
Cookie & Session:
- [ ]
http_only = trueinconfig/session.php - [ ]
same_site = laxorstrict - [ ]
secure = nullortrue(HTTPS only) - [ ]
lifetime = 30(not 120) - [ ]
EncryptCookiesmiddleware active
Correct
// ✅ Throttle on routes
Route::post('/forgot-password', [PasswordResetLinkController::class, 'store'])
->middleware(['guest', 'throttle:5,1']);
Route::post('registrations/{registration}/payment', [PaymentController::class, 'store'])
->middleware(['auth', 'verified', 'throttle:10,1']);
// ✅ Session regeneration
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate(); // Prevent session fixation
return redirect()->intended(route('dashboard'));
}
// ✅ RateLimiter in LoginRequest (5 attempts per minute per email+IP)
public function throttleKey(): string
{
return Str::lower($this->string('email')) . '|' . $this->ip();
}---
Section 8: Software & Data Integrity Failures (A08:2021) — HIGH
CSRF allows attackers to forge state-changing requests. Unsafe deserialization allows remote code execution.
Checklist
CSRF:
- [ ]
VerifyCsrfTokenactive in web group - [ ] Only external webhook routes excluded
- [ ]
@csrfin all Blade forms - [ ] Inertia
X-XSRF-TOKENnot disabled
Deserialization:
- [ ] No
unserialize($request->input(...)) - [ ] No
eval($request->input(...)) - [ ] No
extract($request->all())
Correct
// ✅ Only exclude external webhooks
$middleware->validateCsrfTokens(except: ['/webhooks/toyyibpay']);// ✅ Inertia handles CSRF automatically
router.post('/profile', data);
const { post } = useForm(data);
post('/profile');---
Section 9: Security Logging & Monitoring Failures (A09:2021) — MEDIUM
Checklist
- [ ] Failed login attempts logged
- [ ] Payment failures and exceptions logged
- [ ] No raw passwords or secrets in log entries
- [ ] Monitoring in place (Telescope, Sentry, or similar)
---
Section 10: Server-Side Request Forgery — SSRF (A10:2021) — HIGH
Checklist
- [ ] No
Http::get($request->input('url'))with unvalidated URLs - [ ] User-supplied URLs validated against allowlist or scheme check
// ❌ SSRF vulnerability
Http::get($request->input('url'));
// ✅ Allowlist-validated
$allowedHosts = ['api.toyyibpay.com', 'sandbox.toyyibpay.com'];
$host = parse_url($url, PHP_URL_HOST);
abort_unless(in_array($host, $allowedHosts), 422, 'Invalid URL.');
Http::get($url);---
Additional Checks
Not part of the OWASP Top 10 but critical for Laravel applications.
Command Injection & Dangerous Functions
- [ ] No
exec(),shell_exec(),system()with user input - [ ] No open redirects via user-supplied URLs
- [ ] File uploads validate
mimes:andmax:— filenames not from raw user input
Security Headers
- [ ]
Content-Security-Policywith nonces - [ ]
X-Frame-Optionsset - [ ]
X-Content-Type-Optionsset - [ ]
Strict-Transport-Securityset - [ ]
Referrer-Policyset - [ ]
Permissions-Policyset
---
React + Inertia.js Checks
R1. XSS in React Components
- [ ] No
dangerouslySetInnerHTMLwithoutDOMPurify.sanitize() - [ ]
href/srcnot set from unvalidated user input - [ ] No
eval()ornew Function()with user-controlled strings - [ ] Links validate scheme (
https://orhttp://only)
R2. Inertia.js Data Exposure — CRITICAL
- [ ]
HandleInertiaRequests::share()exposes no passwords, tokens, or internal flags - [ ] Controllers use
->only([...])or API Resources — not rawtoArray() - [ ] Secret keys and admin credentials never passed as Inertia props
- [ ] Inertia v2 History Encryption enabled for sensitive pages
// ❌ Secret key in data-page HTML
'secret_key' => Setting::get('toyyibpay_secret_key')
// ✅ Masked — frontend only needs to know if it is set
'secret_key_set' => $secretKey !== '',
'secret_key_masked' => substr($secretKey, 0, 4) . str_repeat('*', 12),R3. CSRF in Inertia.js
- [ ]
X-XSRF-TOKENnot disabled - [ ] Custom
fetchcalls include CSRF token - [ ] Webhook routes are the ONLY CSRF exclusions
R4. Authentication State in React
- [ ]
auth.userprop excludes password hash, remember tokens, 2FA secrets - [ ] Role checks enforced server-side
- [ ]
auth.usercontains only fields the UI needs
// ✅ Minimal shared auth props
'user' => [
'id' => $request->user()->id,
'name' => $request->user()->name,
'email' => $request->user()->email,
'current_role' => $request->user()->currentRole?->only(['id', 'name', 'slug']),
]R5. Sensitive Data in Browser
- [ ] No API keys hardcoded in React/TypeScript files
- [ ] No sensitive data in
localStorage/sessionStorage - [ ]
VITE_*vars contain no secrets
R6. Dependency Security
- [ ]
npm auditpasses - [ ] React on supported version
- [ ] Third-party libraries reviewed for CVEs
---
Quick Audit Commands
# Dependency vulnerabilities
composer audit
npm audit
# Search for dangerous patterns
grep -r "dangerouslySetInnerHTML" resources/js --include="*.tsx" | grep -v "DOMPurify"
grep -r "\$request->all()" app/Http/Controllers
grep -r "whereRaw\|orderByRaw\|selectRaw" app --include="*.php"
grep -r "eval\|unserialize\|extract\(" app --include="*.php"
grep -r "guarded = \[\]" app/Models---
References
{
"version": "1.0.3",
"organization": "AsyrafHussin",
"date": "March 2026",
"laravelVersion": "13.x",
"phpVersion": "8.3+",
"abstract": "OWASP Top 10 security audit checklist and secure coding reference for Laravel 13 + React/Inertia.js applications. Dual-purpose: run a full OWASP-mapped security audit with PASS/FAIL/N/A output, or use as a secure coding reference when building features. Covers all 10 OWASP Top 10:2021 categories (A01–A10) plus additional Laravel-specific checks and 6 React/Inertia.js-specific security checks including data-page exposure, dangerouslySetInnerHTML XSS, CSRF in Inertia, and sensitive props in shared state. Auto-detects React + Inertia.js stack and applies extended checks. Each rule includes incorrect vs. correct PHP/TypeScript code examples using Laravel 13 and PHP 8.3+ patterns.",
"references": [
"https://owasp.org/www-project-top-ten/",
"https://cheatsheetseries.owasp.org/cheatsheets/Laravel_Cheat_Sheet.html",
"https://laravel.com/docs/13.x/authentication",
"https://laravel.com/docs/13.x/authorization",
"https://laravel.com/docs/13.x/csrf",
"https://laravel.com/docs/13.x/encryption",
"https://laravel.com/docs/13.x/hashing",
"https://laravel.com/docs/13.x/rate-limiting",
"https://inertiajs.com",
"https://github.com/cure53/DOMPurify"
],
"categories": [
{
"name": "Broken Access Control",
"prefix": "sec-access",
"impact": "CRITICAL",
"owasp": "A01:2021",
"description": "Middleware, ownership checks, Gates/Policies, and scoped queries"
},
{
"name": "Cryptographic Failures",
"prefix": "sec-crypto",
"impact": "CRITICAL",
"owasp": "A02:2021",
"description": "Password hashing, encrypted Eloquent casts, signed URLs"
},
{
"name": "Injection",
"prefix": "sec-injection",
"impact": "CRITICAL",
"owasp": "A03:2021",
"description": "SQL injection, mass assignment, XSS in Blade and React/Inertia"
},
{
"name": "Insecure Design",
"prefix": "sec-design",
"impact": "HIGH",
"owasp": "A04:2021",
"description": "Server-side business logic, payment amount validation, admin isolation"
},
{
"name": "Security Misconfiguration",
"prefix": "sec-config",
"impact": "HIGH",
"owasp": "A05:2021",
"description": "APP_DEBUG, APP_KEY, security headers, CORS"
},
{
"name": "Vulnerable & Outdated Components",
"prefix": "sec-components",
"impact": "MEDIUM",
"owasp": "A06:2021",
"description": "composer audit, npm audit, Laravel version support"
},
{
"name": "Identification & Authentication Failures",
"prefix": "sec-auth",
"impact": "HIGH",
"owasp": "A07:2021",
"description": "Throttle middleware, RateLimiter, session regeneration, cookie security"
},
{
"name": "Software & Data Integrity Failures",
"prefix": "sec-integrity",
"impact": "HIGH",
"owasp": "A08:2021",
"description": "CSRF protection, deserialization, unserialize prevention"
},
{
"name": "Security Logging & Monitoring Failures",
"prefix": "sec-logging",
"impact": "MEDIUM",
"owasp": "A09:2021",
"description": "Login attempts, payment failures, no secrets in logs"
},
{
"name": "Server-Side Request Forgery",
"prefix": "sec-ssrf",
"impact": "HIGH",
"owasp": "A10:2021",
"description": "Http::get with user URLs, allowlist validation, internal network blocking"
},
{
"name": "Inertia Data Exposure",
"prefix": "sec-inertia",
"impact": "HIGH",
"owasp": "React/Inertia R2",
"description": "data-page attribute exposure, secret props, API Resources"
}
],
"keyFeatures": [
"Full OWASP Top 10 checklist mapped to Laravel 13 patterns",
"Auto-detects React + Inertia.js stack for extended checks",
"6 React/Inertia.js-specific security checks (R1-R6)",
"PASS/FAIL/N/A audit output format with file:line references",
"Dual-purpose: audit runner + secure coding reference",
"PHP 8.3+ and Laravel 13 code examples",
"DOMPurify integration for dangerouslySetInnerHTML",
"Inertia data-page exposure detection",
"CSP nonce-based Content-Security-Policy guidance"
]
}
Laravel OWASP Security
OWASP Top 10 security audit and secure coding guidelines for Laravel 13 + React/Inertia.js applications.
Overview
This skill provides:
- Full OWASP Top 10 security audit checklist mapped to Laravel 13 patterns
- 6 React/Inertia.js-specific security checks (R1–R6)
- Auto-detection of React + Inertia.js stack
- PASS/FAIL/N/A audit output with
file:linereferences - Secure coding reference with incorrect vs. correct PHP/TSX examples
Categories
OWASP Top 10 Checklist (A01–A10)
1. Broken Access Control (A01:2021) — CRITICAL 2. Cryptographic Failures (A02:2021) — CRITICAL 3. Injection — SQL, Mass Assignment, XSS (A03:2021) — CRITICAL 4. Insecure Design (A04:2021) — HIGH 5. Security Misconfiguration (A05:2021) — HIGH 6. Vulnerable & Outdated Components (A06:2021) — MEDIUM 7. Identification & Authentication Failures + Session (A07:2021) — HIGH 8. Software & Data Integrity Failures — CSRF + Deserialization (A08:2021) — HIGH 9. Security Logging & Monitoring Failures (A09:2021) — MEDIUM 10. Server-Side Request Forgery — SSRF (A10:2021) — HIGH
Additional Checks
- Command Injection & Dangerous Functions
- Security Headers
React + Inertia.js Checks (R1–R6)
- R1. XSS in React Components
- R2. Inertia.js Data Exposure (Critical)
- R3. CSRF in Inertia.js
- R4. Authentication State in React
- R5. Sensitive Data in Browser
- R6. Dependency Security
Usage
Run a security audit:
- "Run OWASP audit on my Laravel app"
- "Security review of app/Http/Controllers"
- "Check my codebase for OWASP vulnerabilities"
Use as a secure coding reference:
- "How do I safely handle file uploads in Laravel?"
- "How do I prevent XSS in React with Inertia?"
- "What is the correct way to pass data from Laravel to Inertia?"
References
Sections
This file defines all sections, their ordering, impact levels, OWASP mapping, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Broken Access Control (sec-broken-access-control)
Impact: CRITICAL OWASP: A01:2021 Description: Middleware, ownership checks, Gates/Policies, and scoped queries. The most common critical vulnerability in web applications. Attackers exploit missing access controls to access other users' data, perform privileged actions, or bypass authorization entirely. Proper server-side enforcement is mandatory — frontend checks alone are never sufficient.
2. Cryptographic Failures (sec-cryptographic-failures)
Impact: CRITICAL OWASP: A02:2021 Description: Password hashing, encrypted Eloquent casts, and signed URLs. Weak or missing cryptography exposes sensitive data at rest and in transit. Passwords stored as plaintext or with weak algorithms (MD5, SHA1) are trivially cracked. API keys and secrets stored in plaintext database columns are exposed on any data breach.
3. Injection Prevention (sec-injection-prevention)
Impact: CRITICAL OWASP: A03:2021 Description: SQL injection prevention and mass assignment protection. String-concatenated SQL queries allow attackers to read, modify, or delete any data in the database. Mass assignment vulnerabilities allow attackers to set fields they should not have access to (e.g., is_admin, role). Always use parameterized queries and $request->validated().
4. XSS & React/Inertia (sec-xss-react-inertia)
Impact: HIGH OWASP: A03:2021 Description: dangerouslySetInnerHTML, DOMPurify, and href/src validation. Cross-site scripting allows attackers to inject and execute scripts in the browser of any user who views the infected page. In React, dangerouslySetInnerHTML without sanitization and unvalidated href attributes are the primary attack vectors. DOMPurify must wrap all user-supplied HTML before rendering.
5. CSRF Protection (sec-csrf-protection)
Impact: HIGH OWASP: A08:2021 Description: VerifyCsrfToken middleware, webhook exclusions, and Inertia CSRF. Cross-site request forgery tricks authenticated users into submitting requests they did not intend. Laravel's VerifyCsrfToken middleware prevents this. Only stateless webhook routes should be excluded — all other routes must remain protected.
6. Security Misconfiguration (sec-security-misconfiguration)
Impact: HIGH OWASP: A05:2021 Description: APP_DEBUG, APP_KEY, security headers, and CORS. Misconfigured environments leak stack traces, expose admin panels, and allow cross-origin attacks. APP_DEBUG=true in production exposes full stack traces and environment details to attackers. Security headers protect against clickjacking, MIME sniffing, and XSS.
7. Authentication & Rate Limiting (sec-authentication-rate-limiting)
Impact: HIGH OWASP: A07:2021 Description: Throttle middleware, RateLimiter, session fixation, and brute force prevention. Weak authentication allows attackers to brute-force credentials, hijack sessions, or bypass login entirely. Rate limiting on login, password reset, and sensitive action routes is mandatory. Session ID must be regenerated after login to prevent fixation attacks.
8. Inertia Data Exposure (sec-inertia-data-exposure)
Impact: HIGH OWASP: React/Inertia R2 Description: Inertia data-page attribute exposure, secret props in shared state, and API Resources. All Inertia props passed from Laravel controllers are embedded in the data-page HTML attribute on initial page load — visible to anyone who views page source. Secret keys, admin flags, or sensitive credentials passed as Inertia props are publicly exposed regardless of frontend rendering logic.
Rule Title Here
Impact: HIGH (optional impact description)
Brief explanation of the rule and why it matters in Laravel 13 + React/Inertia.js applications. Explain the security implications, what an attacker could do if this rule is violated, and which OWASP category it maps to.
Why It Matters
- Risk: What can an attacker do if this rule is violated?
- Impact: What is the consequence for the application or its users?
- OWASP: Which OWASP Top 10 category this maps to
Incorrect
<?php
// ❌ Bad code example — shows the vulnerable pattern
class BadExample
{
public function vulnerableMethod(Request $request)
{
// This demonstrates what NOT to do
// Include a realistic example showing the vulnerability
}
}Problems:
- Specific vulnerability 1
- Specific vulnerability 2
Correct
<?php
declare(strict_types=1);
// ✅ Good code example — shows the secure pattern
class GoodExample
{
public function secureMethod(Request $request): ReturnType
{
// This demonstrates the correct approach
// Using Laravel 13 and PHP 8.3+ features
}
}Why this is safe:
- Specific security benefit 1
- Specific security benefit 2
Recommended Patterns
| Pattern | Use Case |
|---|---|
| Pattern 1 | When to use |
| Pattern 2 | When to use |
Reference: OWASP Laravel Cheat Sheet
Enforce Authentication and Rate Limiting
Impact: HIGH (Prevents brute force, credential stuffing, and session fixation attacks)
Why It Matters
- Risk: Without rate limiting, attackers can attempt thousands of passwords per minute. Without session regeneration, session fixation allows an attacker to set a victim's session ID before login and hijack it after
- Impact: Account takeover, credential stuffing at scale, session hijacking
- OWASP: A07:2021 — Identification and Authentication Failures
Incorrect — No Rate Limiting
<?php
// ❌ Login route with no throttle — allows unlimited attempts
Route::post('/login', [AuthenticatedSessionController::class, 'store']);
// ❌ Password reset with no throttle — enumerable via timing
Route::post('/forgot-password', [PasswordResetLinkController::class, 'store']);
// ❌ Payment route with no throttle — allows mass submission
Route::post('registrations/{registration}/payment', [PaymentController::class, 'store']);<?php
// ❌ Custom auth that doesn't hash passwords
class AuthController extends Controller
{
public function login(Request $request)
{
$user = User::where('email', $request->email)
->where('password', $request->password) // Plaintext comparison!
->first();
}
}<?php
// ❌ No session regeneration after login — session fixation vulnerability
class AuthenticatedSessionController extends Controller
{
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
// Missing session()->regenerate() — same session ID before and after login
return redirect()->intended(route('dashboard'));
}
}Problems:
- No throttle allows unlimited login attempts — brute force and credential stuffing are trivial
- No session regeneration after login allows session fixation — attacker sets victim's session ID and takes over after they log in
- Plaintext password comparison bypasses all hashing protections
Correct — Rate Limiting on Auth Routes
<?php
// ✅ Throttle middleware on sensitive routes
Route::post('/', [AuthenticatedSessionController::class, 'store'])
->middleware('guest');
// Note: throttle is handled inside LoginRequest via RateLimiter
// ✅ Throttle on password reset
Route::post('/forgot-password', [PasswordResetLinkController::class, 'store'])
->middleware(['guest', 'throttle:5,1'])
->name('password.email');
// ✅ Throttle on email verification
Route::post('/email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
->middleware(['auth', 'throttle:6,1'])
->name('verification.send');
// ✅ Throttle on payment submission
Route::post('registrations/{registration}/payment', [PaymentController::class, 'store'])
->middleware(['auth', 'verified', 'role:student', 'throttle:10,1'])
->name('student.payments.store');
// ✅ Throttle on webhook callbacks
Route::post('webhooks/toyyibpay', [ToyyibPayController::class, 'callback'])
->middleware('throttle:60,1');Use RateLimiter in LoginRequest
<?php
declare(strict_types=1);
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.throttle', ['seconds' => $seconds]),
]);
}
public function throttleKey(): string
{
// Key combines email + IP — prevents per-IP bypass
return Str::transliterate(Str::lower($this->string('email')) . '|' . $this->ip());
}
}Always Regenerate Session After Login
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
class AuthenticatedSessionController extends Controller
{
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
// ✅ Regenerate session ID — prevents session fixation
$request->session()->regenerate();
return redirect()->intended(route('dashboard'));
}
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
// ✅ Invalidate and regenerate on logout too
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}Session Configuration
<?php
// ✅ config/session.php — secure session settings
return [
'lifetime' => env('SESSION_LIFETIME', 30), // 30 min — not 120
'http_only' => true,
'same_site' => 'lax',
'secure' => env('SESSION_SECURE_COOKIE'), // Auto-true on HTTPS
'domain' => null,
];Recommended Patterns
| Pattern | Use Case |
|---|---|
RateLimiter::hit() in LoginRequest | Login brute force prevention |
throttle:5,1 middleware | Password reset, email verify routes |
throttle:10,1 middleware | Payment and sensitive action routes |
session()->regenerate() | Always call after successful login |
session()->invalidate() | Always call on logout |
SESSION_LIFETIME=30 | Reduce idle session window |
Reference: Laravel Authentication | Laravel Rate Limiting
Prevent Broken Access Control
Impact: CRITICAL (Prevents unauthorized access to other users' data and privileged actions)
Why It Matters
- Risk: Attackers access other users' records, perform admin actions, or read sensitive data by manipulating IDs or bypassing role checks
- Impact: Full data breach, privilege escalation, unauthorized transactions
- OWASP: A01:2021 — the most common critical vulnerability across web applications
Incorrect
<?php
// ❌ No ownership check — any authenticated user can view any payment
class PaymentController extends Controller
{
public function show(int $id)
{
$payment = Payment::find($id); // No ownership check
return Inertia::render('payments/show', ['payment' => $payment]);
}
}<?php
// ❌ Admin route group without role middleware
Route::prefix('admin')->group(function () {
Route::get('/users', [UserController::class, 'index']);
Route::delete('/users/{user}', [UserController::class, 'destroy']);
// Any authenticated user can reach these routes
});<?php
// ❌ Relying on frontend role check without server-side enforcement
class ReportController extends Controller
{
public function financial()
{
// No middleware — assumes React UI hid the link from non-admins
$data = Payment::all();
return Inertia::render('reports/financial', compact('data'));
}
}Problems:
- Any authenticated user can access any record by changing the ID in the URL
- Role checks only on the frontend are trivially bypassed — users can call routes directly
- Missing middleware on admin route groups exposes privileged actions to all users
Correct
Always Check Ownership
<?php
declare(strict_types=1);
// ✅ Scope resource to authenticated user
class PaymentController extends Controller
{
public function show(int $id)
{
$payment = Payment::where('user_id', auth()->id())
->findOrFail($id);
return Inertia::render('payments/show', ['payment' => $payment]);
}
}Protect Route Groups with Middleware
<?php
// ✅ Role middleware enforced at route level
Route::middleware(['auth', 'role:admin'])->prefix('admin')->name('admin.')->group(function () {
Route::get('/users', [UserController::class, 'index'])->name('users.index');
Route::delete('/users/{user}', [UserController::class, 'destroy'])->name('users.destroy');
});
// ✅ Multiple roles allowed
Route::middleware(['auth', 'role:teacher,moderator,admin'])->prefix('manage')->group(function () {
Route::resource('classes', ClassManagementController::class);
});Use Gates and Policies
<?php
declare(strict_types=1);
// ✅ Policy — define authorization logic separately
class PaymentPolicy
{
public function view(User $user, Payment $payment): bool
{
return $user->id === $payment->user_id;
}
public function update(User $user, Payment $payment): bool
{
return $user->id === $payment->user_id
&& $payment->status === 'pending';
}
}
// ✅ Controller uses Gate/Policy
class PaymentController extends Controller
{
public function show(Payment $payment)
{
$this->authorize('view', $payment);
return Inertia::render('payments/show', ['payment' => $payment]);
}
}Scope All Queries to the Authenticated User
<?php
declare(strict_types=1);
// ✅ Use global scope or always filter by authenticated user
class RegistrationController extends Controller
{
public function index()
{
$registrations = Registration::where('student_id', auth()->id())
->with(['class', 'payments'])
->latest()
->paginate(20);
return Inertia::render('student/registrations/index', compact('registrations'));
}
}Mirror Server-Side Checks — Never Trust Frontend Alone
<?php
declare(strict_types=1);
// ✅ Middleware enforces access — React UI hiding a link is not security
Route::middleware(['auth', 'verified', 'role:admin'])->group(function () {
Route::get('/admin/reports/financial', [ReportController::class, 'financial']);
});
// React link may be hidden from non-admins — but the route is still protected
// Even if a user manually navigates to the URL, middleware blocks themRecommended Patterns
| Pattern | Use Case |
|---|---|
->where('user_id', auth()->id())->findOrFail($id) | Scope any resource to current user |
$this->authorize('action', $model) | Policy-based authorization per action |
Route::middleware('role:admin') | Protect admin route groups |
abort_unless($condition, 403) | Inline authorization guard |
Gate::authorize('action', $model) | Gate-based authorization in services |
Reference: OWASP Laravel Cheat Sheet
Prevent Cryptographic Failures
Impact: CRITICAL (Prevents plaintext credential storage and sensitive data exposure)
Why It Matters
- Risk: Plaintext or weakly hashed passwords are trivially cracked on any data breach. Unencrypted API keys in the database are exposed to any SQL dump
- Impact: Full account takeover, payment gateway compromise, third-party service abuse
- OWASP: A02:2021 — covers all forms of weak, missing, or misapplied cryptography
Incorrect
<?php
// ❌ Password stored as plaintext
class RegisteredUserController extends Controller
{
public function store(Request $request)
{
User::create([
'name' => $request->name,
'email' => $request->email,
'password' => $request->password, // Plaintext — NEVER do this
]);
}
}<?php
// ❌ Weak MD5 or SHA1 hashing — trivially cracked via rainbow tables
$hashedPassword = md5($request->password);
$hashedPassword = sha1($request->password);<?php
// ❌ API secret stored as plaintext in the database
Setting::set('toyyibpay_secret_key', $request->secret_key); // Plaintext in DB
// Now any SQL dump or DB read exposes the secret key<?php
// ❌ Sensitive one-time link without signing — ID can be tampered
Route::get('/email/verify/{id}', function ($id) {
User::findOrFail($id)->markEmailAsVerified();
});
// Attacker can verify any account by guessing or incrementing the IDProblems:
- Plaintext passwords are exposed in any database breach
- MD5/SHA1 hashes are pre-computed in rainbow tables and cracked in seconds
- Plaintext API keys in the DB are exposed in any DB dump or admin query
- Unsigned verification links allow account takeover by ID manipulation
Correct
Always Hash Passwords with Bcrypt
<?php
declare(strict_types=1);
// ✅ Use Laravel's 'hashed' cast — automatically bcrypt on set
class User extends Authenticatable
{
protected $casts = [
'password' => 'hashed', // Automatically hashed via Hash::make()
];
}
// ✅ Or use Hash::make() explicitly
User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);Encrypt Sensitive Fields at Rest
<?php
declare(strict_types=1);
// ✅ Use Laravel's 'encrypted' cast for sensitive DB columns
class Setting extends Model
{
protected $casts = [
'value' => 'encrypted', // Auto-encrypts using APP_KEY via AES-256-CBC
];
}
// ✅ Or use Crypt facade manually
Setting::set('toyyibpay_secret_key', Crypt::encryptString($request->secret_key));
// Decrypt on read
$secretKey = Crypt::decryptString(Setting::get('toyyibpay_secret_key'));Use Signed URLs for Sensitive One-Time Actions
<?php
declare(strict_types=1);
// ✅ Signed URL for email verification — cannot be tampered
Route::get('/email/verify/{id}/{hash}', [VerifyEmailController::class, '__invoke'])
->middleware(['auth', 'signed', 'throttle:6,1'])
->name('verification.verify');
// ✅ Generate signed URL with expiry
$url = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)],
);
// ✅ Generate signed URL for password reset
$url = URL::signedRoute('password.reset', ['token' => $token, 'email' => $email]);Mask Secrets in UI — Never Send Full Keys to Frontend
<?php
declare(strict_types=1);
// ✅ Mask secret in Inertia prop — frontend only needs to know if key is set
class SettingsController extends Controller
{
public function index(): Response
{
$secretKey = Crypt::decryptString(Setting::get('toyyibpay_secret_key', ''));
return Inertia::render('admin/settings/index', [
'toyyibpay' => [
'secret_key_set' => $secretKey !== '',
'secret_key_masked' => $secretKey !== ''
? substr($secretKey, 0, 4) . str_repeat('*', 12)
: '',
],
]);
}
}Recommended Patterns
| Pattern | Use Case |
|---|---|
'password' => 'hashed' cast | User password fields |
'value' => 'encrypted' cast | API keys, secrets, tokens in DB |
Crypt::encryptString() / decryptString() | Manual encrypt/decrypt |
URL::signedRoute() | Password reset, email verify links |
URL::temporarySignedRoute() | Time-limited one-time links |
Reference: Laravel Encryption | OWASP A02:2021
Enforce CSRF Protection
Impact: HIGH (Prevents forged cross-site requests that perform actions on behalf of authenticated users)
Why It Matters
- Risk: A malicious website tricks an authenticated user's browser into submitting a state-changing request (payment, delete, password change) to your application without the user's knowledge
- Impact: Unauthorized transactions, account deletion, privilege changes
- OWASP: A08:2021 — Software and Data Integrity Failures (includes CSRF)
Incorrect
<?php
// ❌ Excluding all POST routes from CSRF — disables protection globally
$middleware->validateCsrfTokens(except: ['*']);<?php
// ❌ Excluding authenticated routes from CSRF without justification
$middleware->validateCsrfTokens(except: [
'/admin/settings', // Authenticated — should NOT be excluded
'/payments/*', // Authenticated — should NOT be excluded
'/webhooks/*', // OK — external webhook callback
]);// ❌ Custom fetch bypassing Inertia router without CSRF token
async function deleteAccount() {
await fetch('/account', {
method: 'DELETE',
// Missing X-XSRF-TOKEN header — CSRF protection not sent
});
}<?php
// ❌ Traditional Blade form without @csrf
<form method="POST" action="/profile">
<input type="text" name="name">
<button type="submit">Update</button>
{{-- Missing @csrf — request will be rejected or vulnerable --}}
</form>Problems:
- Excluding authenticated routes from CSRF allows cross-site forged requests
- Custom
fetchcalls bypass Inertia's automatic CSRF header injection - Blade forms without
@csrfare either rejected (if protection is on) or vulnerable (if excluded)
Correct
Only Exclude Stateless Webhook Routes
<?php
// ✅ Only exclude external webhook callbacks that cannot send CSRF tokens
// bootstrap/app.php
$middleware->validateCsrfTokens(except: [
'/webhooks/toyyibpay', // Third-party callback — cannot include CSRF token
'/webhooks/stripe', // Third-party callback — cannot include CSRF token
]);
// All authenticated routes remain CSRF-protectedInertia Handles CSRF Automatically
import { router, useForm } from '@inertiajs/react';
// ✅ Inertia router automatically sends X-XSRF-TOKEN header
router.post('/profile', { name: 'John' });
// ✅ useForm also handles CSRF automatically
const { data, setData, post } = useForm({ name: '' });
post('/profile'); // X-XSRF-TOKEN sent automaticallyCustom fetch Must Include CSRF Token
import axios from 'axios';
// ✅ Use axios (Inertia default) — it reads XSRF-TOKEN cookie automatically
await axios.delete('/account');
// ✅ If using native fetch, read the token from the cookie
function getCsrfToken(): string {
return document.cookie
.split('; ')
.find(row => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] ?? '';
}
await fetch('/account', {
method: 'DELETE',
headers: {
'X-XSRF-TOKEN': decodeURIComponent(getCsrfToken()),
'Content-Type': 'application/json',
},
});Always Use @csrf in Blade Forms
{{-- ✅ @csrf in every POST/PUT/DELETE Blade form --}}
<form method="POST" action="/profile">
@csrf
@method('PATCH')
<input type="text" name="name" value="{{ auth()->user()->name }}">
<button type="submit">Update Profile</button>
</form>Verify Webhooks with Signature Instead of CSRF
<?php
declare(strict_types=1);
// ✅ Webhook route excluded from CSRF — but verified by signature
class ToyyibPayController extends Controller
{
public function callback(Request $request): Response
{
// Verify authenticity via payment gateway signature/token
// instead of CSRF (which third parties cannot provide)
$billCode = $request->input('billcode');
$billStatus = $request->input('status_id');
// Process the verified callback
$this->processPayment($billCode, $billStatus);
return response('OK');
}
}Recommended Patterns
| Pattern | Use Case |
|---|---|
validateCsrfTokens(except: ['/webhooks/*']) | Only exclude third-party callbacks |
Inertia router.post/put/delete | All form submissions — CSRF automatic |
useForm hook | Forms with Inertia — CSRF automatic |
axios for custom HTTP calls | Reads XSRF-TOKEN cookie automatically |
@csrf in Blade forms | Non-Inertia forms |
| Webhook signature verification | Authenticate excluded routes differently |
Reference: Laravel CSRF Protection | Inertia.js Security
Prevent Inertia.js Data Exposure
Impact: HIGH (Prevents sensitive data from being exposed in the data-page HTML attribute on initial load)
Why It Matters
- Risk: All Inertia props passed from Laravel controllers are serialized into the
data-pageattribute of the<div id="app">element on the initial page load. This HTML is visible to anyone who views page source — even before JavaScript runs - Impact: API keys, secret tokens, admin flags, and other sensitive data embedded in Inertia props are publicly exposed regardless of what the React UI renders or hides
- OWASP: React/Inertia R2 — Inertia.js Data Exposure
<!-- Everything in Inertia props is visible here — anyone can View Source -->
<div id="app" data-page="{"component":"admin/settings/index",
"props":{"toyyibpay":{"secret_key":"sk_live_abc123..."}}}">Incorrect
<?php
// ❌ Full secret key sent to frontend as Inertia prop
class SettingsController extends Controller
{
public function index(): Response
{
return Inertia::render('admin/settings/index', [
'toyyibpay' => [
'secret_key' => Setting::get('toyyibpay_secret_key'), // Full key exposed!
'category_code' => Setting::get('toyyibpay_category_code'),
],
]);
}
}<?php
// ❌ Raw model passed as Inertia prop — exposes all database columns
class UserController extends Controller
{
public function show(User $user): Response
{
return Inertia::render('admin/users/show', [
'user' => $user, // Includes password_hash, remember_token, 2FA secrets
]);
}
}<?php
// ❌ Shared props expose sensitive fields
class HandleInertiaRequests extends Middleware
{
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user(), // Includes password, remember_token, all columns
],
]);
}
}<?php
// ❌ Admin-only flag passed to non-admin pages — flag is still in data-page HTML
return Inertia::render('student/dashboard', [
'is_admin' => auth()->user()->hasRole('admin'), // Visible in source
'payment_config' => ['toyyibpay_key' => config('toyyibpay.secret')], // Exposed!
]);Problems:
- Full API keys in Inertia props are embedded in HTML source — no authentication required to read them
- Raw Eloquent models include every column including
password,remember_token, and any sensitive cast fields - Admin-only props are still in
data-pageHTML even if the React UI never renders them
Correct
Mask Secrets — Never Send Full Keys
<?php
declare(strict_types=1);
// ✅ Mask the key — frontend only needs to know if it is configured
class SettingsController extends Controller
{
public function index(): Response
{
$secretKey = (string) Setting::get('toyyibpay_secret_key', '');
return Inertia::render('admin/settings/index', [
'toyyibpay' => [
'secret_key_set' => $secretKey !== '',
'secret_key_masked' => $secretKey !== ''
? substr($secretKey, 0, 4) . str_repeat('*', 12)
: '',
'category_code' => (string) Setting::get('toyyibpay_category_code', ''),
'sandbox_mode' => Setting::get('toyyibpay_sandbox_mode', '0') === '1',
'callback_url' => route('toyyibpay.callback'),
],
]);
}
}Use ->only() or API Resources — Never Raw Models
<?php
declare(strict_types=1);
// ✅ Only pass fields the UI actually needs
class UserController extends Controller
{
public function show(User $user): Response
{
return Inertia::render('admin/users/show', [
'user' => $user->only(['id', 'name', 'email', 'status', 'created_at']),
]);
}
}
// ✅ Or use an API Resource for consistent field selection
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'status' => $this->status,
'roles' => $this->roles->pluck('name'),
'created_at' => $this->created_at->toDateString(),
// password, remember_token, 2FA fields — NOT included
];
}
}
return Inertia::render('admin/users/show', [
'user' => new UserResource($user),
]);Limit Shared Props to What the UI Needs
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
// ✅ Only fields the UI needs — no password, remember_token, or 2FA
'user' => $request->user() ? [
'id' => $request->user()->id,
'name' => $request->user()->name,
'email' => $request->user()->email,
'status' => $request->user()->status,
'current_role' => $request->user()->currentRole?->only(['id', 'name', 'slug']),
'roles' => $request->user()->roles->map->only(['id', 'name', 'slug']),
] : null,
],
'flash' => [
'success' => fn () => $request->session()->get('success'),
'error' => fn () => $request->session()->get('error'),
],
]);
}
}Never Pass Secrets or Admin Data to Non-Admin Pages
<?php
declare(strict_types=1);
// ✅ Use middleware to ensure admin data only reaches admin routes
// routes/web.php
Route::middleware(['auth', 'role:admin'])->prefix('admin')->group(function () {
Route::get('/settings', [SettingsController::class, 'index']);
// Even masked sensitive data only sent to authenticated admin pages
});
// ✅ Student dashboard — no admin flags, no secret keys
Route::middleware(['auth', 'verified', 'role:student'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
// Only student-relevant data passed here
});Recommended Patterns
| Pattern | Use Case |
|---|---|
$model->only(['id', 'name', ...]) | Limit fields in Inertia props |
new UserResource($user) | Consistent field selection via API Resource |
secret_key_masked + secret_key_set | Show masked key — never full value |
$request->user()->only([...]) | Shared auth props in HandleInertiaRequests |
Admin routes in role:admin middleware group | Ensure sensitive props never reach non-admin pages |
Reference: Inertia.js Shared Data | Laravel API Resources
Prevent SQL Injection and Mass Assignment
Impact: CRITICAL (Prevents database compromise and unauthorized field manipulation)
Why It Matters
- Risk (SQL Injection): Attackers read, modify, or delete any data in the database, or execute OS commands, by injecting SQL into raw queries
- Risk (Mass Assignment): Attackers set fields they should not have access to —
is_admin,role,user_id— by sending extra POST parameters - Impact: Full database exfiltration, privilege escalation, unauthorized data modification
- OWASP: A03:2021 — Injection
Incorrect — SQL Injection
<?php
// ❌ String concatenation in raw query — SQL injectable
class ClassController extends Controller
{
public function index(Request $request)
{
$sort = $request->input('sort');
// Attacker sends: sort=name; DROP TABLE users; --
$classes = DB::select("SELECT * FROM classes ORDER BY {$sort}");
}
}<?php
// ❌ User input in whereRaw without binding
$classes = Class::whereRaw("name LIKE '%" . $request->search . "%'")->get();
// Attacker sends: search=' OR '1'='1<?php
// ❌ orderByRaw with unvalidated user input
$results = Payment::orderByRaw($request->input('column') . ' ' . $request->input('direction'))->get();Problems:
- Any user input concatenated into a SQL string is injectable
whereRaw,selectRaw,orderByRawall pass directly to the database engine- Attacker can exfiltrate all data, bypass WHERE clauses, or run destructive queries
Correct — SQL Injection Prevention
<?php
declare(strict_types=1);
// ✅ Use parameterized bindings in raw queries
$classes = DB::select('SELECT * FROM classes WHERE status = ?', [$request->status]);
// ✅ Named bindings
$classes = DB::select(
'SELECT * FROM classes WHERE status = :status AND teacher_id = :teacher',
['status' => $request->status, 'teacher' => auth()->id()],
);
// ✅ whereRaw with bindings
$classes = Class::whereRaw('name LIKE ?', ['%' . $request->search . '%'])->get();
// ✅ Whitelist-validate column names before use in orderByRaw
$allowedColumns = ['name', 'created_at', 'price'];
$allowedDirections = ['asc', 'desc'];
$column = in_array($request->column, $allowedColumns) ? $request->column : 'created_at';
$direction = in_array($request->direction, $allowedDirections) ? $request->direction : 'asc';
$payments = Payment::orderByRaw("{$column} {$direction}")->get();Incorrect — Mass Assignment
<?php
// ❌ $guarded = [] — all fields are mass assignable, including is_admin, role
class User extends Model
{
protected $guarded = [];
}
// Attacker sends POST: { "name": "John", "is_admin": true }
User::create($request->all()); // is_admin is now set to true<?php
// ❌ $request->all() passed directly to create/update
class PostController extends Controller
{
public function store(Request $request)
{
Post::create($request->all()); // Any field can be set by the attacker
}
}<?php
// ❌ forceFill with unvalidated user input
$user->forceFill($request->all())->save(); // Bypasses $fillable entirelyProblems:
$guarded = []allows attackers to set any field includingis_admin,role,user_id$request->all()passes every submitted parameter directly to the modelforceFillwith unvalidated data completely bypasses Laravel's mass assignment protection
Correct — Mass Assignment Prevention
<?php
declare(strict_types=1);
// ✅ Define $fillable explicitly — only user-submittable fields
class Post extends Model
{
protected $fillable = [
'title',
'body',
'category_id',
];
// user_id, published_at, is_featured are NOT in fillable
}<?php
declare(strict_types=1);
// ✅ Always use $request->validated() — only validated fields pass through
class PostController extends Controller
{
public function store(StorePostRequest $request): RedirectResponse
{
$post = Post::create([
...$request->validated(),
'user_id' => auth()->id(), // Set sensitive fields explicitly
]);
return redirect()->route('posts.show', $post);
}
}
class StorePostRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
'category_id' => ['required', 'integer', 'exists:categories,id'],
// user_id is NOT here — cannot be submitted by attackers
];
}
}<?php
declare(strict_types=1);
// ✅ forceFill only with hardcoded fields — never with user input
class NewPasswordController extends Controller
{
public function store(Request $request): RedirectResponse
{
// SAFE — hardcoded field list, not user-controlled
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
}
}Recommended Patterns
| Pattern | Use Case |
|---|---|
whereRaw('col = ?', [$value]) | Raw SQL with user input |
| Whitelist array for column names | Dynamic ORDER BY / WHERE column |
$fillable = ['field1', 'field2'] | All models — explicit allowlist |
$request->validated() | Mass operations in controllers |
Set user_id explicitly | Ownership fields — never in fillable |
Reference: OWASP Laravel Cheat Sheet | Laravel Eloquent Mass Assignment
Prevent Security Misconfiguration
Impact: HIGH (Prevents information disclosure, header-based attacks, and environment exposure)
Why It Matters
- Risk: Misconfigured environments expose stack traces, skip security headers, or allow any origin to call your API
- Impact: Attacker reads full stack traces (file paths, DB credentials hinted), clickjacks the app, or calls authenticated API endpoints from any domain
- OWASP: A05:2021 — Security Misconfiguration
Incorrect — Debug Mode in Production
// ❌ APP_DEBUG=true in production
// Any exception exposes: file paths, environment variables, stack trace, DB config hints
APP_DEBUG=true
APP_ENV=production // Contradiction — debug should be false in production<?php
// What a user sees when APP_DEBUG=true throws an exception:
// Illuminate\Database\QueryException: SQLSTATE[42S02]
// Connection to /var/www/app/.env database failed
// Stack trace shows every internal file pathCorrect — Environment Configuration
# ✅ .env — production values
APP_ENV=production
APP_DEBUG=false
APP_KEY=base64:your-unique-64-char-key-here
# Database — restricted user, not root
DB_USERNAME=aittendance_user # Not root
DB_PASSWORD=strong-random-password
# Session — secure for HTTPS
SESSION_SECURE_COOKIE=true
SESSION_LIFETIME=30Incorrect — Missing Security Headers
<?php
// ❌ No security headers middleware — browser has no protection instructions
// App is vulnerable to:
// - Clickjacking (no X-Frame-Options)
// - MIME sniffing (no X-Content-Type-Options)
// - Inline script XSS (no Content-Security-Policy)
// - Protocol downgrade (no Strict-Transport-Security)Correct — Security Headers Middleware
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Vite;
use Symfony\Component\HttpFoundation\Response;
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
// Generate CSP nonce — used by Vite and @routes directive
$nonce = Vite::useCspNonce();
$response = $next($request);
// Set headers not handled by CDN/reverse proxy
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
$response->headers->set('Content-Security-Policy', implode('; ', [
"default-src 'self'",
"script-src 'self' 'nonce-{$nonce}'", // Nonce eliminates unsafe-inline
"style-src 'self' 'unsafe-inline' https://fonts.bunny.net",
"font-src 'self' https://fonts.bunny.net",
"img-src 'self' data: blob: https:",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
]));
// Only set in non-production — Cloudflare/CDN handles these in production
if (! app()->environment('production')) {
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return $response;
}
}<?php
// ✅ Register SecurityHeaders in web middleware group
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
App\Http\Middleware\HandleInertiaRequests::class,
App\Http\Middleware\SecurityHeaders::class,
]);
}){{-- ✅ Pass nonce to @routes so Ziggy script passes CSP --}}
@routes(nonce: Vite::cspNonce())
@viteReactRefresh
@vite(['resources/css/app.css', 'resources/js/app.tsx'])Incorrect — CORS Misconfiguration
<?php
// ❌ Wildcard allowed origins — any website can call your authenticated API
return [
'allowed_origins' => ['*'], // DANGEROUS for authenticated routes
'allowed_methods' => ['*'],
];Correct — CORS Configuration
<?php
// ✅ config/cors.php — restrict to your domain
return [
'paths' => ['api/*'],
'allowed_origins' => ['https://yourdomain.com'], // Explicit domain only
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE'],
'allowed_headers' => ['Content-Type', 'X-Requested-With', 'Authorization'],
'supports_credentials' => true,
];Recommended Patterns
| Pattern | Use Case |
|---|---|
APP_DEBUG=false | Production environment always |
Vite::useCspNonce() | Eliminate unsafe-inline from script-src |
SecurityHeaders middleware | Apply all security headers in web group |
allowed_origins: ['https://...'] | CORS — never use * for authenticated routes |
| CDN handles HSTS, X-Frame, X-Content-Type | Avoid duplicate headers when behind Cloudflare |
Reference: OWASP Secure Headers | MDN Content-Security-Policy
Prevent XSS in React and Inertia.js
Impact: HIGH (Prevents script injection that executes in users' browsers)
Why It Matters
- Risk: Attackers inject
<script>tags or event handlers into user-rendered HTML that execute in the browser of every user who views the infected content - Impact: Session hijacking, credential theft, malicious redirects, defacement
- OWASP: A03:2021 — applies to React even though React auto-escapes JSX expressions
React auto-escapes {variable} expressions. However, dangerouslySetInnerHTML bypasses this entirely. Any teacher-entered, admin-entered, or user-generated rich text rendered with dangerouslySetInnerHTML without sanitization is a stored XSS vulnerability.
Incorrect
// ❌ dangerouslySetInnerHTML without sanitization
// Teacher can inject: <script>document.cookie</script> in their notes
<div
className="prose prose-sm"
dangerouslySetInnerHTML={{ __html: sessionNote.notes }}
/>// ❌ Class description rendered without sanitization
// Admin or teacher could inject scripts via the description field
<div dangerouslySetInnerHTML={{ __html: classData.description }} />// ❌ href from user input without scheme validation
// javascript:alert(1) is a valid href that executes script on click
<a href={user.website}>Visit Website</a>// ❌ eval() or new Function() with user-controlled strings
const fn = new Function(userInput) // Executes arbitrary user code
eval(userDefinedExpression)Problems:
dangerouslySetInnerHTMLpasses raw HTML directly to the DOM — no React escaping- A teacher/admin with edit access becomes an attack vector for all users viewing their content
javascript:scheme inhrefexecutes on click even in Reacteval()andnew Function()with user strings allow arbitrary code execution
Correct
Install DOMPurify
npm install dompurify @types/dompurifySanitize All User-Supplied HTML
import DOMPurify from 'dompurify';
// ✅ Sanitize before rendering — DOMPurify strips dangerous tags and attributes
<div
className="prose prose-sm dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(sessionNote.notes) }}
/>
// ✅ Same for any teacher/admin/user-entered rich text
<div
className="prose prose-sm dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(classData.description) }}
/>Create a Reusable Safe HTML Component
import DOMPurify from 'dompurify';
interface SafeHtmlProps {
html: string;
className?: string;
}
// ✅ Reusable component — use everywhere instead of raw dangerouslySetInnerHTML
export function SafeHtml({ html, className }: SafeHtmlProps) {
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }}
/>
);
}
// Usage
<SafeHtml html={sessionNote.notes} className="prose prose-sm dark:prose-invert" />
<SafeHtml html={classData.description} className="prose prose-sm" />Validate URL Scheme Before Using in href
// ✅ Validate scheme before setting href — blocks javascript: URLs
function getSafeUrl(url: string): string {
if (url.startsWith('https://') || url.startsWith('http://')) {
return url;
}
return '#';
}
<a href={getSafeUrl(user.website)} target="_blank" rel="noopener noreferrer">
Visit Website
</a>Server-Side Sanitization as an Extra Layer
<?php
declare(strict_types=1);
use HTMLPurifier;
use HTMLPurifier_Config;
// ✅ Sanitize rich text on the server before storing (belt-and-suspenders)
class SessionNoteController extends Controller
{
public function store(StoreSessionNoteRequest $request, ClassSession $session): RedirectResponse
{
$config = HTMLPurifier_Config::createDefault();
$purifier = new HTMLPurifier($config);
SessionNote::create([
'session_id' => $session->id,
'notes' => $purifier->purify($request->validated('notes')),
'homework' => $purifier->purify($request->validated('homework', '')),
]);
return redirect()->back()->with('success', 'Session notes saved.');
}
}What DOMPurify Allows vs. Strips
| Allowed (safe) | Stripped (dangerous) |
|---|---|
<p>, <b>, <i>, <ul>, <li> | <script>, <iframe>, <object> |
<a href="https://..."> | <a href="javascript:..."> |
<img src="https://..."> | onclick, onerror, onload attributes |
<h1>–<h6>, <blockquote> | <style> with expression() |
Recommended Patterns
| Pattern | Use Case |
|---|---|
DOMPurify.sanitize(html) | All dangerouslySetInnerHTML usage |
<SafeHtml html={...} /> | Reusable sanitized renderer |
getSafeUrl(url) | User-supplied href or src attributes |
| Server-side HTMLPurifier | Belt-and-suspenders for stored content |
{{ }} in Blade | User content in Blade templates (auto-escaped) |
Reference: DOMPurify | OWASP XSS Prevention Cheat Sheet