TL;DR. The fastest-growing way to lose money on AI is not a clever attack. It is a provider API key that ends up in a file the browser can read, or in a commit, because an AI assistant wired the integration the quick way. Code review does not reliably catch it, because the key is one line in a sea of green diff. This post shows exactly how the leak happens in a Laravel app, why it slips through, and how to fail your build the moment a key lands somewhere it should not. The scanner is free and open source.
An exposed OpenAI or Anthropic key is a bearer credential. Whoever holds it can spend against your account until you notice and rotate it. Anthropic, OpenAI, and Google all say the same thing in their security guidance: route every model call through your own backend and never put the key in client-side code, because a leaked key lets a stranger make requests on your behalf, run up charges, and reach account data (OpenAI: API key safety).
The reason this is worth a whole article in 2026 is that the shape of the mistake has changed. It used to be a junior developer hardcoding a key. Now it is an AI coding assistant scaffolding "add a chat feature to my Laravel app" and, to make the demo work, calling the provider directly from a Vue or Blade view with the key inlined. The code runs. The feature works. The key is now in resources/js, or interpolated into a Blade template, or sitting in a .env that got committed. In OWASP terms this is LLM02, sensitive information disclosure, except the sensitive information is your own credential.
There are four common exits, and all of them look harmless in a pull request.
The most direct leak. A component needs to call the model, so the key is passed into JavaScript:
<script>
const OPENAI_KEY = "{{ config('services.openai.key') }}";
// ...fetch('https://api.openai.com/v1/chat/completions', { headers: { Authorization: `Bearer ${OPENAI_KEY}` }})
</script>Once that renders, the key is in the page source, readable with View Source. A bundler doing the same thing inside resources/js ships it in a file served from public/. Anyone can open developer tools and read it.
env() called after config:cacheA subtler cousin. Calling env('OPENAI_API_KEY') outside a config file returns null in production once config:cache runs, so developers "fix" it by exposing the value somewhere reachable, or by inlining a Vite variable that gets baked into the public build. The advice is consistent across the ecosystem: read keys through config(), never env() at runtime, and never hand them to the frontend (Securely storing API keys in Laravel).
.env.env is gitignored by default, but a copied .env.production, a Docker file with the key baked in, or a hurried git add -f puts the live secret into history. Once it is in a commit, rotating is the only real fix.
A key echoed into a log line, an exception page, or a dd() left in a controller. Not the browser bundle, but still reachable by anyone who can trigger the error.
These leaks survive review for a structural reason: the dangerous line is indistinguishable from a correct one at a glance. config('services.openai.key') is exactly what you want in a server-side client, and exactly what you must never do in a Blade <script>. The difference is the location, not the code. A reviewer scanning forty changed files for logic and naming is not also reliably grepping every template and JS file for credential patterns. Humans are bad at this on purpose; it is tedious, and tedium is what machines are for.
So the answer is not "review harder." It is to make the machine fail the build when a key lands in the wrong place.
laravel-ai-lint is a static scanner for exactly this class of mistake. It is a dev dependency, so it never ships to production:
composer require --dev laravelsecurityaudit/laravel-ai-lintRun the scan:
php artisan ai-lint:scanIt walks the paths that matter (app, config, routes, resources, database, public by default), runs a set of secret rules over every file, and reports each finding with its file and line. The rule that matters most here is secrets.ai_provider_key, which fires on an OpenAI, Anthropic, or Google key found in a tracked file. When that key turns up under resources/js, resources/views, or public, the finding is escalated, because that is the day-one mistake: the credential is reachable by the browser.
The scan exits non-zero when a finding meets your severity threshold, which is what turns it into a gate rather than a report.
- run: php artisan ai-lint:scan --min-severity=critical --format=sarif --output=ai-lint.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ai-lint.sarifNow a pull request that introduces an exposed key turns the build red and drops an annotation into GitHub code scanning, pointing at the exact file and line. The leak is stopped before merge, not discovered on your invoice.
A leaked key is the loudest failure, but it is not the only one a scanner can see in source. laravel-ai-lint also ships PHPStan rules that flag two patterns the moment they appear:
aiLint.llmOutputToUnsafeSink: model output used directly inside a dangerous sink, for example DB::raw($response->content) or exec($prism->asText()->text). This is OWASP LLM05, insecure output handling: treating text a model generated as if you wrote it. Model output is untrusted input.aiLint.concatenatedPrompt: a prompt assembled by string concatenation that mixes in dynamic data, for example ->withPrompt('Summarise: '.$input). This is the seam where prompt injection lives, OWASP LLM01.# with phpstan/extension-installer the rules register automatically
vendor/bin/phpstan analyseThese are deliberately lexical, single-expression checks. They catch the model call sitting directly inside the sink or the prompt argument. Output that first flows through a variable, and deeper data-flow analysis, is what a runtime guard and a human audit are for. Think of the lint as the cheap first gate that catches the obvious cases on every commit, not as a replacement for either.
Finding the key is the first half. The response is the half that matters, and the order is not optional:
config(), used by a backend route the frontend calls. The browser talks to your app; your app talks to the provider.Two minutes, no production risk, since it is a dev dependency:
composer require --dev laravelsecurityaudit/laravel-ai-lint
php artisan ai-lint:scanIf it comes back clean, you have a CI gate to add and a quiet afternoon. If it does not, you just found a key before someone else did, and rotating it now is a far better day than rotating it after a surprise bill.
laravel-ai-lint send my code anywhere?No. It runs locally and in your CI. It is a static scanner with no network calls and no telemetry.
It walks a fixed set of paths and runs pattern rules over each file, so it adds seconds, not minutes. The PHPStan rules run inside the analysis you already do.
No. The scan reads the files, resources/js included, so a key inlined into a Vue component or baked into a Vite variable is exactly what it is built to find.
Yes, MIT licensed. Install it as a dev dependency and gate CI with it at no cost.
laravel-ai-lint is MIT licensed and part of a small family of Laravel AI-security packages: egress guard for what leaks at runtime, a circuit breaker for runaway spend, and a processing ledger for GDPR records. The lint is where to start, because the exposed key is the leak that costs you money first.
This is defensive tooling, not legal advice, and not affiliated with or endorsed by Laravel, Laravel LLC, or any AI provider.
A senior engineer reviews every line your assistant wrote, credentials and wiring included. Fixed price, every finding with a fix.