← All posts
·8 min read·Reuben Santoso

How to Hide API Keys From AI Coding Agents (Claude Code, Cursor, Windsurf)

AI coding agents hardcode API keys because they can't see your .env setup. Here's how keys leak into client bundles and git, and how to catch it before you ship.

Quick Answer

AI coding agents — Claude Code, Cursor, Windsurf — hardcode API keys because they have no idea your .env file exists or what your secrets manager looks like. When an agent needs a key to make code work, it takes the shortest path: paste the literal string into the file it's editing. If that file is a client component, the key ships to every browser that loads your app. If it's a seed script, the key lands in git. Either way, the fix is the same: give the agent explicit instructions about secrets, keep keys out of files it reads, and run a deterministic scan like npx @api-doctor/cli . before you commit — because agents will keep making this mistake no matter how good the model gets.

The problem: your agent can't see your secrets setup

Here's the thing people miss about agent-generated key leaks. The agent isn't being careless in some vague way. It's making a perfectly rational decision with the information it has, and the information it has is wrong.

When you ask Claude Code to "add email sending with Resend," the agent needs an API key for the code to work. It looks around. Maybe you pasted the key into the chat. Maybe it read your .env file while exploring the project. Maybe it found the key in an old test file. Wherever it came from, the agent now holds a literal string, and it has a choice: reference process.env.RESEND_API_KEY and trust that you've wired up environment variables correctly, or paste the string inline and guarantee the code runs on the first try.

Agents are trained to produce code that works. Inline keys work. So that's what you get, more often than anyone wants to admit.

The deeper issue is that the agent has no model of your deployment boundary. It doesn't know which files run on your server and which get bundled and shipped to browsers. It doesn't know that .env.local is gitignored but scripts/seed.ts isn't. It sees files, and it edits files. The distinction between "secret that lives in server memory" and "string in a source file" — the distinction your entire security posture depends on — simply doesn't exist in the agent's view of your project.

And because agents generate a lot of code fast, you review less of it. That's the whole appeal. Nobody using Cursor to scaffold an integration is reading every generated line with the paranoia they'd apply to a stranger's pull request. The one line that matters — the one with the key in it — looks exactly like working code, because it is working code. It works for you, and it works for everyone who extracts the key from your bundle.

A real example: a Resend key in the browser bundle

This is a pattern I've seen agents produce repeatedly. You ask for a contact form that sends an email. The agent writes one file, because one file is the simplest correct-looking answer:

// app/components/ContactForm.tsx
"use client";

import { Resend } from "resend";
import { useState } from "react";

const resend = new Resend("re_123abc456def789ghi012jkl345mno");

export default function ContactForm() {
  const [email, setEmail] = useState("");

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await resend.emails.send({
      from: "hello@yourapp.com",
      to: email,
      subject: "Thanks for reaching out",
      html: "<p>We got your message.</p>",
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <button type="submit">Send</button>
    </form>
  );
}

Run this locally and it sends an email. Ship it and you've published your Resend key to the world. That "use client" directive at the top means everything in the file — including the key on line 6 — gets compiled into the JavaScript bundle that every visitor downloads. Anyone can open DevTools, search your bundle for re_, and start sending email as you. Your domain reputation, your bill, their spam.

Supabase has its own version of this. Agents regularly grab the service_role key — the one that bypasses row-level security entirely — and drop it into a client-side Supabase client, because a service-role key makes every query succeed and agents love code that succeeds:

// lib/supabase.ts — imported by client components
import { createClient } from "@supabase/supabase-js";

export const supabase = createClient(
  "https://xyzcompany.supabase.co",
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIn0..."
);

That's not just a leaked key. That's your entire database, readable and writable by anyone who looks at your JavaScript, with every row-level security policy silently bypassed. The app behaves identically in testing, which is exactly why nobody catches it.

How api-doctor catches this

api-doctor's hardcoded-api-key rule exists across its Resend, Supabase, Firebase, and Twilio providers, and it works on the syntax tree, not on vibes. It flags any literal key string passed to a provider SDK constructor, and separately flags provider keys referenced inside files carrying a "use client" directive — because a key in a client file is a leak even if the value comes from an environment variable prefixed with NEXT_PUBLIC_.

✗ Resend: 1 critical issue

  resend-api-key-hardcoded
    app/components/ContactForm.tsx:6
    Literal API key passed to Resend constructor in a client component.
    This key will be included in the browser bundle.
    Severity: critical
    Security: CWE-798 (Use of Hard-coded Credentials)

✗ Supabase: 1 critical issue

  supabase-service-role-in-client
    lib/supabase.ts:5
    service_role key in client-reachable module. This bypasses
    row-level security for anyone who reads your bundle.
    Severity: critical

The detection is deterministic — the same AST pattern either matches or it doesn't, which matters when the code you're scanning was written by a model. Asking another LLM to review agent output means the reviewer shares the training priors that produced the bug; I've written before about why AST rules beat LLM prompts for exactly this class of problem.

How to use api-doctor

One command, no config:

npx @api-doctor/cli .

It scans your project, detects which providers you're using, and runs every rule for those providers. For agent-heavy workflows, the move is to make it automatic, because the entire failure mode here is "nobody looked." Add it as a pre-commit hook:

# .husky/pre-commit
npx @api-doctor/cli . --fail-on critical

Or in CI, so nothing merges with a key in it:

# .github/workflows/api-doctor.yml
- name: Scan API integrations
  run: npx @api-doctor/cli . --fail-on critical

The scan catches more than keys — the same rule set flags missing idempotency keys on payment retries and the auth flow mistakes agents make when they pattern-match a login flow from stale training data. Leaked secrets are the loudest failure, but they're one entry in a longer list of things agents get confidently wrong.

Reducing the leaks at the source

Scanning catches leaks. You can also make them less likely to happen:

Tell the agent your policy, explicitly. Every major agent reads an instruction file — CLAUDE.md for Claude Code, .cursorrules for Cursor, .windsurfrules for Windsurf. Two lines change behavior meaningfully:

Never write literal API keys, tokens, or secrets into any file.
Always reference process.env.VARIABLE_NAME and tell me which
variables I need to add to .env.local.

Don't paste keys into the chat. Once a key is in the conversation, it's in the agent's working context and can resurface in any file it writes, including files you didn't ask it to touch. Add keys to .env.local yourself, by hand, and just tell the agent the variable name.

Keep .env out of the agent's reach where your tool allows it. Claude Code supports permission deny rules for reading specific files; Cursor has an ignore file. An agent can't inline a value it never read. This isn't airtight — keys arrive through pasted error logs and old commits too — which is why the scanner stays in the loop.

Treat generated code as unreviewed code. Because it is. The agent is a very fast contributor with no security training and no memory of your last incident.

Frequently asked questions

Q: Why do AI coding agents hardcode API keys? Because inlining the key is the shortest path to code that runs, and the agent's training data is full of examples where humans did exactly that. The agent has no visibility into your environment variable setup, so referencing process.env is — from its perspective — a gamble on configuration it can't see.

Q: Is it safe to put an API key in a Next.js client component? No. Anything in a "use client" file is compiled into the browser bundle. NEXT_PUBLIC_-prefixed variables are also bundled — the prefix is an explicit opt-in to public exposure, which is fine for a Stripe publishable key and catastrophic for a Resend or service-role key.

Q: How do I stop Claude Code or Cursor from leaking keys? Layer three things: an instruction file (CLAUDE.md / .cursorrules) that bans literal secrets, keys added to .env.local by hand rather than pasted into chat, and a deterministic pre-commit scan. Any single layer fails sometimes; the combination has to fail three times in a row.

Q: What if an agent already committed my key? Rotate it immediately in the provider dashboard. Don't bother rewriting git history first — scrapers index public commits within minutes of a push, so treat the key as burned the moment it landed. Then audit the provider's usage logs.

Next Steps

Try api-doctor

Deterministic AST rules for AI-generated API integrations. Not a prompt.

npx @api-doctor/cli .