← All posts
·8 min read·Reuben Santoso

Secrets Management for Claude Code: A Practical Setup Guide

Claude Code will read your .env and can paste real key values into generated code. Here's the guardrail setup: gitignore, CLAUDE.md rules, permission denies, and a pre-commit scan.

Quick Answer

Claude Code will read and write any file you let it touch, and that includes .env files. There's no built-in policy that stops it from printing a real secret value into generated code, a commit message, or a scratch file — if the key is in its context, it can come back out anywhere. Securing it is a four-layer setup that takes about ten minutes: proper .env + .gitignore hygiene, a permission deny rule so the agent can't read env files at all, a CLAUDE.md instruction banning inlined secrets, and npx @api-doctor/cli . as a pre-commit check for whatever slips past the first three layers.

The problem: Claude Code has your secrets in context

Claude Code is genuinely good at integration work. Point it at your project, say "add Resend email sending," and it explores your codebase, finds your patterns, and writes code that fits. That exploration is the feature — and it's also the exposure.

While exploring, Claude Code reads files. If your .env.local is sitting in the project root — and it is, that's where Next.js wants it — the agent may read it to understand your configuration. Perfectly reasonable. But now the literal value of RESEND_API_KEY is in the model's working context, and the model doesn't have a taint-tracking system that marks it as radioactive. It's just a string it knows, like your component names and your import style.

Strings the model knows have a way of showing up in output. In generated code, when inlining the value is the surest way to make the code run. In a commit message, when the agent summarizes what it configured. In a debug script it writes to test the integration. None of this is the model being malicious or even unusual — it's a text generator holding text, doing what text generators do.

The second half of the problem is that you're reviewing less than you think. Claude Code produces a lot of files quickly, and the whole point of using it is not reading every line. A key inlined on line 4 of a file you skimmed looks identical to a key referenced properly, at the glance-speed most agent output actually gets reviewed at. This isn't hypothetical — the same failure produces the broader mess of agents leaking keys into client bundles across every tool in this category.

A real example: the key that came out of .env

Here's the shape of the failure, from a session doing exactly what everyone does. The ask: "add Resend email sending for the contact form." Claude Code explored the project, saw RESEND_API_KEY=re_8kFm2xQp... in .env.local, and generated this:

// lib/email.ts
import { Resend } from "resend";

// Configured with the project's Resend key
const resend = new Resend("re_8kFm2xQpT7vNc3bYw9dRj4sLh6gAz1eU");

export async function sendContactEmail(to: string, message: string) {
  return resend.emails.send({
    from: "contact@yourapp.com",
    to,
    subject: "New contact form submission",
    html: `<p>${message}</p>`,
  });
}

Notice what happened. The agent didn't invent a placeholder. It didn't write process.env.RESEND_API_KEY. It used the real value, copied out of the env file it had read three tool calls earlier, because a literal string is guaranteed to work and an environment reference depends on runtime configuration the model can't verify. From the model's point of view, this is the more reliable version of the code.

The integration works immediately. The demo goes great. The file gets committed, because nothing about lib/email.ts looks wrong in a diff unless you know what your key looks like by heart. Now the key is in git history, and if that repo is ever public — or ever becomes public, which is how these things actually happen — scrapers will find it in minutes.

What you wanted was one character different in spirit and completely different in consequence:

const resend = new Resend(process.env.RESEND_API_KEY);

The setup: four layers, ten minutes

Layer 1: .env hygiene (the part everyone half-does)

Make sure every env file variant is gitignored — including the ones agents like to create, like .env.backup or .env.copy when they're "being helpful" during a refactor:

# .gitignore
.env
.env.*
!.env.example

Keep a committed .env.example with variable names and no values. This matters more with an agent than without one: when Claude Code sees .env.example, it learns your variable names — which is exactly the information you want it to have, and nothing more.

# .env.example — safe to commit
RESEND_API_KEY=
SUPABASE_SERVICE_ROLE_KEY=

Layer 2: block reads of env files

Claude Code supports permission rules in .claude/settings.json. Deny reads on env files and the agent can't pull values into context in the first place:

{
  "permissions": {
    "deny": [
      "Read(.env)",
      "Read(.env.*)",
      "Read(**/.env)",
      "Read(**/.env.*)"
    ]
  }
}

This is the highest-leverage layer, because it attacks the root cause: a value that never enters context can't leak out of it. It's not airtight — keys still arrive through pasted error output, old commits, and your own chat messages — but it closes the widest channel.

Layer 3: tell it the policy in CLAUDE.md

Claude Code reads CLAUDE.md at the start of every session. Add an explicit secrets policy:

## Secrets policy

- Never write a literal API key, token, or secret value into any file,
  commit message, or command. This includes "temporary" debug scripts.
- Always reference secrets as process.env.VARIABLE_NAME.
- When an integration needs a new secret, add the name to .env.example
  and tell me to fill in the value myself.

Be honest about what this layer is: probabilistic steering of a probabilistic system. It measurably reduces inlined secrets, and a long session can still produce one anyway. That's not a reason to skip the instruction — it's the reason the next layer exists.

Layer 4: deterministic scan before commit

This is the backstop, and it's the only layer that doesn't depend on the model behaving. api-doctor parses your code into a syntax tree and flags literal key strings passed to provider SDK constructors — Resend, Supabase, Firebase, Twilio — plus secrets referenced in client components that would ship to the browser:

✗ Resend: 1 critical issue

  resend-api-key-hardcoded
    lib/email.ts:4
    Literal API key passed to Resend constructor.
    Move to process.env.RESEND_API_KEY.
    Severity: critical
    Security: CWE-798 (Use of Hard-coded Credentials)

Wire it in as a pre-commit hook so it runs without anyone remembering to run it:

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

And in CI as the last line of defense:

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

The reason this layer is a scanner and not "ask Claude to double-check its own work" is the reason you can't test AI code with AI: the reviewing model shares the priors of the writing model. An AST rule doesn't have priors. The pattern matches or it doesn't, every run, regardless of how convincing the surrounding code looks.

As a bonus, the same scan covers the rest of Claude Code's integration blind spots — payment retries missing idempotency keys, and the auth flow mistakes that come from pattern-matching stale examples. Secrets are the layer-one problem, but they're rarely the only thing wrong in a generated integration.

What this setup doesn't cover

Worth being explicit about the residual risk. If you paste a key into the chat, it's in context and layers 1–2 can't help; the instruction and the scanner are what's left. If a key appears in a stack trace or a log the agent reads while debugging, same story. And a secrets manager (Doppler, Infisical, Vault) is still the right call for production — this guide is about the development loop, where the agent lives.

The mental model that makes all of this coherent: Claude Code is a fast, capable contributor who has never sat through your security onboarding. You wouldn't give that contributor raw production credentials and skip review on their commits. Give the agent names instead of values, write the policy down where it reads, and keep one deterministic check between its output and your repo.

Frequently asked questions

Q: Can Claude Code read my .env file? Yes, by default — any file in the project it hasn't been explicitly denied. That's why the permission deny rule in .claude/settings.json is the highest-leverage part of this setup.

Q: Is the CLAUDE.md instruction enough on its own? No. It reduces the rate of inlined secrets, but it's steering a probabilistic system, not enforcing a policy. Pair it with the deny rule (so values stay out of context) and the pre-commit scan (so leaks get caught deterministically).

Q: What about secrets in commit messages or scratch files? Covered by the same layers: the deny rule keeps values out of context so they can't appear anywhere, and the CLAUDE.md policy explicitly names commit messages and debug scripts. Scan scratch directories too if you commit them — better, don't commit them.

Q: Should I use a secrets manager instead of .env files? For production, yes, always. For local development with an agent, you'll likely still have env files in the loop, which is why the deny rule and the scanner matter regardless of what holds your production secrets.

Next Steps

Try api-doctor

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

npx @api-doctor/cli .