🕮14 min read · 2,642 words
This is Part 3 of our AI Code Audit Series — covering the essential audits every team should run when building with AI agentic editors.
Security is where AI-generated code fails most dangerously. Not most frequently — business logic errors are more common. But most dangerously, because security vulnerabilities don’t produce visible symptoms until they’re exploited. Your application runs perfectly, your tests pass, your users are happy — and somewhere, someone is extracting data, escalating privileges, or preparing to do so.
AI agentic editors introduce security vulnerabilities in consistent, predictable patterns. This is both the problem and the solution — because predictable failure patterns can be systematically checked. This post is that checklist: every significant security category where AI-generated code fails, with real examples and the correct implementation for each.
Why AI-Generated Code Has Predictable Security Failures
AI coding tools are trained on publicly available code — open source repositories, tutorials, Stack Overflow answers, documentation examples. A significant portion of that code has security problems. Tutorials prioritise clarity over security. Stack Overflow answers prioritise making something work. Legacy code carries vulnerabilities that were never addressed.
The AI learns these patterns and reproduces them. It generates code that solves the stated problem — correctly, cleanly, and without the security considerations that weren’t in the prompt. It has no threat model. It doesn’t know which data is sensitive, which endpoints are exposed to untrusted input, or which operations require authorisation checks. It answers the prompt in front of it without understanding what surrounds it.
The result is a specific, consistent set of vulnerability patterns that appear across AI-generated codebases regardless of which tool was used. Once you know these patterns, finding them becomes systematic rather than intuitive.
Vulnerability Category 1 — Broken Access Control
This is the most common critical vulnerability in AI-generated Laravel code. The AI generates endpoints that check whether a user is authenticated — but not whether the authenticated user has permission to access the specific resource they’re requesting.
// VULNERABLE — AI-generated, checks auth but not ownership
Route::middleware('auth:sanctum')->group(function () {
Route::get('/invoices/{invoice}', function (Invoice $invoice) {
return new InvoiceResource($invoice);
});
Route::put('/invoices/{invoice}', function (Request $request, Invoice $invoice) {
$invoice->update($request->validated());
return new InvoiceResource($invoice);
});
Route::delete('/invoices/{invoice}', function (Invoice $invoice) {
$invoice->delete();
return response()->json(['message' => 'Deleted']);
});
});
// SECURE — ownership verified on every operation
Route::middleware('auth:sanctum')->group(function () {
Route::get('/invoices/{invoice}', function (Invoice $invoice) {
Gate::authorize('view', $invoice); // Checks ownership in Policy
return new InvoiceResource($invoice);
});
Route::put('/invoices/{invoice}', function (Request $request, Invoice $invoice) {
Gate::authorize('update', $invoice);
$invoice->update($request->validated());
return new InvoiceResource($invoice);
});
Route::delete('/invoices/{invoice}', function (Invoice $invoice) {
Gate::authorize('delete', $invoice);
$invoice->delete();
return response()->json(['message' => 'Deleted']);
});
});
The corresponding Policy that the Gate calls:
// app/Policies/InvoicePolicy.php
class InvoicePolicy
{
public function view(User $user, Invoice $invoice): bool
{
return $user->id === $invoice->user_id
|| $user->hasRole('admin');
}
public function update(User $user, Invoice $invoice): bool
{
return $user->id === $invoice->user_id
&& $invoice->status === 'draft'; // Can't edit sent invoices
}
public function delete(User $user, Invoice $invoice): bool
{
return $user->id === $invoice->user_id
&& $invoice->status === 'draft';
}
}
Audit check: For every route that accepts a model ID as a parameter, verify that a Policy or explicit ownership check is applied. Search for route definitions and confirm each one has a corresponding Gate::authorize() or $this->authorize() call.
# Find routes that use model binding without obvious authorization
grep -r "Route::" routes/ --include="*.php" -A3 | grep -E "\{[a-z]+\}"
Vulnerability Category 2 — SQL Injection
AI-generated code occasionally produces raw SQL with string interpolation — particularly in complex queries where the AI chose a raw approach for flexibility. This is rare in standard Eloquent usage but appears in reporting queries, search features, and admin panels where complex filtering is required.
// VULNERABLE — string interpolation in raw query
public function searchOrders(Request $request)
{
$status = $request->input('status');
$search = $request->input('search');
$orders = DB::select("
SELECT * FROM orders
WHERE status = '$status'
AND (customer_name LIKE '%$search%'
OR order_number LIKE '%$search%')
ORDER BY created_at DESC
");
return response()->json($orders);
}
// SECURE — parameterised bindings
public function searchOrders(Request $request)
{
$request->validate([
'status' => 'nullable|in:pending,processing,shipped,delivered,cancelled',
'search' => 'nullable|string|max:100',
]);
$status = $request->input('status');
$search = $request->input('search');
$query = Order::query();
if ($status) {
$query->where('status', $status); // Eloquent handles binding
}
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('customer_name', 'LIKE', '%' . $search . '%')
->orWhere('order_number', 'LIKE', '%' . $search . '%');
});
}
return OrderResource::collection(
$query->orderByDesc('created_at')->paginate(20)
);
}
// If raw SQL is genuinely required — use bindings
$orders = DB::select("
SELECT * FROM orders
WHERE status = ?
AND (customer_name LIKE ? OR order_number LIKE ?)
ORDER BY created_at DESC
", [$status, "%{$search}%", "%{$search}%"]);
Audit check: Search for all raw SQL usage and verify every variable is bound, never interpolated.
# Find raw SQL with potential interpolation
grep -rn "DB::select\|DB::statement\|DB::raw" app/ --include="*.php"
grep -rn '\$[a-z].*SQL\|sql.*\$[a-z]' app/ --include="*.php"
Vulnerability Category 3 — Mass Assignment
AI-generated controllers frequently use $request->all() or pass the full request to model create/update methods without filtering. This allows attackers to set fields that should never be user-controllable — is_admin, account_balance, email_verified_at, role.
// VULNERABLE — mass assignment with unfiltered input
public function update(Request $request, User $user)
{
$user->update($request->all()); // Attacker can set is_admin=true
return new UserResource($user);
}
// ALSO VULNERABLE — only validates, doesn't restrict fields
public function update(Request $request, User $user)
{
$request->validate(['name' => 'required', 'email' => 'required|email']);
$user->update($request->all()); // Still passes all fields including unvalidated ones
}
// SECURE — explicit field selection
public function update(Request $request, User $user)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,' . $user->id,
'phone' => 'nullable|string|max:20',
]);
// Only update the explicitly validated fields
$user->update($validated);
return new UserResource($user);
}
// Also ensure $fillable is explicitly defined in the model
// NEVER use $guarded = [] in production models
class User extends Authenticatable
{
protected $fillable = [
'name',
'email',
'phone',
'password',
// is_admin, role, email_verified_at are NOT here
];
}
Audit check: Search for $request->all() and $request->except() usage in controllers. Verify every model has explicit $fillable definitions, never $guarded = [].
grep -rn "request()->all()\|\$request->all()" app/ --include="*.php"
grep -rn "guarded = \[\]" app/ --include="*.php"
Vulnerability Category 4 — Sensitive Data Exposure
AI-generated API resources and responses frequently return more data than they should. The AI returns the full model because that’s what the prompt described — it doesn’t know which fields are sensitive.
// VULNERABLE — exposes sensitive fields
public function show(User $user)
{
return response()->json($user);
// Returns: id, name, email, password, remember_token,
// stripe_customer_id, two_factor_secret,
// email_verified_at, created_at, updated_at
}
// ALSO VULNERABLE — toArray() still exposes too much
public function show(User $user)
{
return response()->json($user->toArray());
}
// SECURE — explicit API Resource with only safe fields
// app/Http/Resources/UserResource.php
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'avatar_url' => $this->avatar_url,
'role' => $this->role,
'created_at' => $this->created_at->toDateString(),
// password, stripe_id, 2FA fields — never exposed
];
}
}
// In model — ensure sensitive fields are hidden
class User extends Authenticatable
{
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
'stripe_customer_id',
];
}
Audit check: Every API endpoint should return an API Resource, never a raw model or toArray(). Check every controller method that returns a response and verify it uses a Resource class.
grep -rn "response()->json(\$" app/Http/Controllers --include="*.php"
# Any result that passes a model variable directly is a finding
Vulnerability Category 5 — Missing Rate Limiting
AI-generated authentication endpoints, password reset flows, OTP verification, and API endpoints that consume external services consistently lack rate limiting. The AI implements the functionality correctly but doesn’t add the protection layer.
// VULNERABLE — no rate limiting on sensitive endpoints
Route::post('/login', [AuthController::class, 'login']);
Route::post('/forgot-password', [AuthController::class, 'sendResetLink']);
Route::post('/verify-otp', [AuthController::class, 'verifyOtp']);
// SECURE — rate limiting on all sensitive endpoints
Route::middleware('throttle:5,1')->group(function () {
// 5 attempts per minute
Route::post('/login', [AuthController::class, 'login']);
Route::post('/verify-otp', [AuthController::class, 'verifyOtp']);
});
Route::middleware('throttle:3,60')->group(function () {
// 3 attempts per hour
Route::post('/forgot-password', [AuthController::class, 'sendResetLink']);
Route::post('/register', [AuthController::class, 'register']);
});
// For API endpoints — general rate limiting
Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
// 60 requests per minute per authenticated user
Route::apiResource('orders', OrderController::class);
});
// Custom rate limiter with user-specific limits
// In RouteServiceProvider::boot()
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(60)->by($request->user()->id)
: Limit::perMinute(10)->by($request->ip());
});
Audit check: List every authentication-related and sensitive-action route. Verify each has appropriate throttle middleware. No login, registration, password reset, or OTP endpoint should be unthrottled.
Vulnerability Category 6 — Insecure File Handling
File upload handling in AI-generated code is consistently dangerous — missing MIME validation, accepting executable extensions, storing files in publicly accessible locations, and using original filenames.
// VULNERABLE — multiple file upload security failures
public function uploadDocument(Request $request)
{
$file = $request->file('document');
$filename = $file->getClientOriginalName(); // Original name — path traversal risk
$file->move(public_path('uploads'), $filename); // Public directory
return response()->json(['path' => 'uploads/' . $filename]);
}
// SECURE — comprehensive file upload validation
public function uploadDocument(Request $request)
{
$request->validate([
'document' => [
'required',
'file',
'max:10240', // 10MB max
'mimes:pdf,doc,docx,xls,xlsx,jpg,jpeg,png,webp',
],
]);
$file = $request->file('document');
// Verify MIME type from file content, not just extension
$detectedMime = $file->getMimeType();
$allowedMimes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'image/jpeg',
'image/png',
'image/webp',
];
if (!in_array($detectedMime, $allowedMimes)) {
abort(422, 'File type not permitted');
}
// Randomise filename — prevents path traversal and enumeration
$extension = $file->extension();
$filename = Str::uuid() . '.' . $extension;
// Store outside public directory
$path = $file->storeAs(
'documents/' . auth()->id(),
$filename,
'private' // Uses storage/app/private — not publicly accessible
);
Document::create([
'user_id' => auth()->id(),
'original_name' => $file->getClientOriginalName(), // Store for display
'stored_path' => $path,
'mime_type' => $detectedMime,
'size' => $file->getSize(),
]);
return response()->json(['message' => 'Document uploaded successfully']);
}
// Serve files through authenticated controller — never direct URL
public function downloadDocument(Document $document)
{
Gate::authorize('download', $document);
return Storage::disk('private')->download(
$document->stored_path,
$document->original_name
);
}
Audit check: Find every file upload handler. Verify MIME validation from content not extension, randomised storage names, private storage disk, and authenticated download routes.
grep -rn "->move(\|storeAs\|store(\|->store" app/ --include="*.php"
grep -rn "public_path.*upload\|public/upload" app/ --include="*.php"
Vulnerability Category 7 — Hardcoded Secrets
AI-generated configuration code, API integration snippets, and database connection examples frequently contain hardcoded credentials — either placeholder values that look real or actual credentials copied from examples.
// VULNERABLE — credentials in code
class PaymentService
{
private string $apiKey = 'rzp_live_ABC123XYZ789';
private string $secret = 'secret_key_here';
public function __construct()
{
$this->client = new RazorpayClient($this->apiKey, $this->secret);
}
}
// ALSO VULNERABLE — in config files committed to version control
// config/payment.php
return [
'razorpay_key' => 'rzp_live_ABC123XYZ789',
'razorpay_secret' => 'actual_secret_here',
];
// SECURE — environment variables only
class PaymentService
{
public function __construct(
private string $apiKey = '',
private string $secret = '',
) {
$this->apiKey = config('payment.razorpay_key');
$this->secret = config('payment.razorpay_secret');
if (empty($this->apiKey) || empty($this->secret)) {
throw new \RuntimeException('Payment gateway credentials not configured');
}
$this->client = new RazorpayClient($this->apiKey, $this->secret);
}
}
// config/payment.php — references environment only
return [
'razorpay_key' => env('RAZORPAY_KEY'),
'razorpay_secret' => env('RAZORPAY_SECRET'),
];
// .env — never committed to version control
// RAZORPAY_KEY=rzp_live_ABC123XYZ789
// RAZORPAY_SECRET=actual_secret_here
// .env.example — committed, with placeholder values only
// RAZORPAY_KEY=
// RAZORPAY_SECRET=
Audit check: Scan for patterns that look like API keys, secrets, and passwords in code files. Use automated tools for this — human review misses things that pattern matching catches.
# Install git-secrets or use grep patterns
grep -rn "api_key\s*=\s*['\"][a-zA-Z0-9]\|secret\s*=\s*['\"][a-zA-Z0-9]\|password\s*=\s*['\"][a-zA-Z0-9]" app/ config/ --include="*.php"
# Check for common API key patterns
grep -rn "sk_live_\|pk_live_\|rzp_live_\|AKIA[0-9A-Z]" . --include="*.php" --include="*.js" --include="*.env"
Vulnerability Category 8 — Missing Input Validation
AI-generated controllers frequently validate the fields described in the prompt and silently pass through everything else. Validation rules are also often too permissive — using string instead of string|max:255, or missing type constraints entirely.
// VULNERABLE — incomplete validation
public function store(Request $request)
{
$request->validate([
'title' => 'required', // No type, no max length
'body' => 'required', // Could be 10MB of content
'tags' => 'array', // Array contents not validated
]);
Post::create($request->all()); // Passes all fields including unvalidated ones
}
// SECURE — comprehensive validation
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255|min:5',
'body' => 'required|string|max:50000',
'tags' => 'nullable|array|max:10',
'tags.*' => 'string|max:50|regex:/^[a-zA-Z0-9\-]+$/',
'category_id' => 'required|integer|exists:categories,id',
'published_at' => 'nullable|date|after:now',
'is_featured' => 'boolean',
]);
// Only create with validated fields
Post::create($validated);
}
Audit check: Every controller method that accepts input should have a validate() call or use a Form Request class. The validated fields should cover every field used downstream — no $request->input() calls for fields that weren’t validated.
The Security Audit Checklist — Complete Reference
Use this checklist for every significant AI-generated feature before it merges:
Access Control
- Every route that accepts a model ID has a Policy or explicit ownership check
- Admin-only routes are protected by role middleware, not just auth
- Soft-deleted records cannot be accessed by constructing their URL directly
- Cross-tenant data access is impossible even for authenticated users
Input Handling
- All user input is validated with explicit type, length, and format rules
- No raw SQL with string interpolation exists anywhere
- File uploads validate MIME from content, randomise filenames, use private storage
- No $request->all() passed to model create/update without explicit field selection
- All models have explicit $fillable — no $guarded = []
Output and Data Exposure
- All API responses use Resource classes — no raw model serialisation
- Sensitive fields are in $hidden on all models
- Error responses don’t expose stack traces, SQL queries, or file paths in production
- Uploaded files are served through authenticated routes, never direct public URLs
Authentication and Session
- Login, registration, password reset, and OTP endpoints have rate limiting
- Password reset tokens expire and are single-use
- Session tokens are regenerated after login
- API tokens have appropriate expiry for the use case
Secrets and Configuration
- No API keys, passwords, or secrets in source code or config files
- .env is in .gitignore and has never been committed
- .env.example exists with placeholder values for all required variables
- Production environment has APP_DEBUG=false and APP_ENV=production
Infrastructure
- HTTPS enforced — HTTP redirects to HTTPS in production
- Security headers present: CSP, X-Frame-Options, X-Content-Type-Options
- CORS configured with explicit allowed origins — not wildcard in production
- Dependencies audited for known vulnerabilities via composer audit
Automating the Mechanical Parts
Not every security check requires manual review. Add these tools to your CI pipeline to catch pattern-based vulnerabilities automatically on every pull request.
# composer.json — add to require-dev
{
"require-dev": {
"enlightn/enlightn": "^2.0",
"nunomaduro/larastan": "^2.0",
"psalm/psalm": "^5.0"
}
}
# Run Enlightn security checks
php artisan enlightn --ci
# Run PHPStan at maximum level
./vendor/bin/phpstan analyse app --level=8
# Audit composer dependencies for known vulnerabilities
composer audit
# Check for secrets in codebase
# Install truffleHog or git-secrets as pre-commit hook
Automated tools catch pattern-based vulnerabilities — SQL injection patterns, known vulnerable package versions, obvious misconfigurations. They do not catch logic-level vulnerabilities — missing ownership checks, incorrect permission boundaries, business logic that creates security gaps. Both layers are necessary.
Next in the series: Code Quality Audit — how to identify and address the technical debt that AI agentic editors leave behind at speed.
If you want a security audit run on your existing Laravel application — identifying vulnerabilities before they’re exploited — our team at Softcrony is happy to help.