Blog / Tooling
Tooling · 8 min read · June 28, 2026

Your Laravel AI App Is Leaking Its OpenAI Key to the Browser

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.

The expensive version of a small mistake

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.

How the key actually escapes in a Laravel app

There are four common exits, and all of them look harmless in a pull request.

1. The key reaches the frontend bundle

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.

2. env() called after config:cache

A 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).

3. The committed .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.

4. The debug surface

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.

Why your review process misses it

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.

Catching it automatically

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-lint

Run the scan:

php artisan ai-lint:scan

It 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.

Failing CI, with SARIF for the Security tab

- 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.sarif

Now 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.

The other half: unsafe AI wiring, not just keys

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 analyse

These 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.

If the scan finds a key, rotate it

Finding the key is the first half. The response is the half that matters, and the order is not optional:

  1. Rotate first. Generate a new key in the provider dashboard and revoke the old one. A key that has been in a browser bundle or a public commit must be treated as known to strangers, even with no evidence of misuse yet. Deleting the line from your code does not un-leak it.
  2. Move the call server-side. The key belongs only in server configuration, read through config(), used by a backend route the frontend calls. The browser talks to your app; your app talks to the provider.
  3. Purge it from history if it was committed. Removing the file in a new commit leaves the secret in every earlier one. Rewrite history if you can, but rotation in step one is what actually protects you.
  4. Add the gate so it cannot recur. The leak happened because nothing failed the build. The scan in CI is that missing check.

Do this today

Two minutes, no production risk, since it is a dev dependency:

composer require --dev laravelsecurityaudit/laravel-ai-lint
php artisan ai-lint:scan

If 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.

Frequently asked questions

Does 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.

Will it slow my pipeline?

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.

We use Vue or Inertia, not Blade. Does that matter?

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.

Is it really free?

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.

Add a security gate to your AI-powered app.

A senior engineer reviews every line your assistant wrote, credentials and wiring included. Fixed price, every finding with a fix.