Skip to content

Keeping API keys out of the agent's reach

A coding agent needs an API key, or a login that does the same job, to reach its model. The key is billed to you, and anyone who holds it can spend on your account, under your name. In this lesson we take a fixture agent whose key sits in a settings file it can read and commit, and move the key out of the files it can read in two steps. Then we run a small secret scanner over the fixture and the session transcript, and we end with the rule for a key you think has leaked.

The fixture is a small program in place of your coding agent. It never calls a model. It does the one thing this lesson is about, which is to find a key at start and say where the key came from. The setting names it uses are close to Claude Code’s, and the prose says which real setting each step maps to, checked against the vendor’s documentation on the date at the end of the lesson [1]. Other coding agents have the same three places for a key under other names. Every key in the fixture is fake. You don’t need your real key for any step.

The fixture is in the course repository under site/examples/coding-with-agents/keys-and-secrets/fixture-repo/. Copy that directory somewhere, or work in a clone of the course repository, and change into it. The course repository ignores every .env. So the application’s secrets file is committed as env.sample. Run cp env.sample .env once before you start. git checkout -- . puts every tracked file back.

Inside are agent.py, the fixture agent, vault.py, the lesson’s replacement for the command-line tool of a password manager, scan.py, the scanner, a .env file with two fake application secrets, a transcript.txt from an earlier session, and .agent/settings.json, the agent’s settings file. Open that file.

{
"env": {
"AGENT_API_KEY": "sk-fake-lesson-key-0000-0000-0000-c0de"
},
"permissions": {
"deny": []
}
}

The env block sets an environment variable for every session, and here it sets the key. Start the agent and it tells you so.

Example · run it

Run this, and compare what you see with the output below.

Terminal window
python3 agent.py
Output
key sk-fake-...c0de from .agent/settings.json (env.AGENT_API_KEY)
deny rules: none

Output verified in CI from site/examples/coding-with-agents/keys-and-secrets/where_is_the_key.py.

That file has two problems. The agent can read it, like any file in its working directory. Whenever the agent looks at its own configuration, the key ends up in the session’s context. And the file is meant to be committed: in Claude Code, the matching .claude/settings.json is the shared project file that the whole team checks into source control, while .claude/settings.local.json is the personal one, which Claude Code adds to your git excludes the first time it writes it [1]. A key in the shared file is one git push away from everyone with access to the repository.

The examples in this lesson aren’t graded. Each one is a command you run in the fixture, with the output the course itself checks on every build. The checkpoints ask about the decisions, because deciding where a key may be is what this lesson teaches.

Before you change anything, run the scanner from the last section once, so you know the starting count. It reads dotfiles too.

Example · run it

Run the scanner over the directory.

Terminal window
python3 scan.py
Output
.env:1  password assignment
.env:2  fake token
transcript.txt:4  fake key
.agent/settings.json:3  fake key
4 finding(s)

Output verified in CI from site/examples/coding-with-agents/keys-and-secrets/scan_fixture.py.

Four findings, and the last one is the key you are about to move.

Step one: the key becomes an environment variable

Section titled “Step one: the key becomes an environment variable”

Delete the env block from .agent/settings.json. The file then reads {"permissions": {"deny": []}}. Now give the agent the key through your shell instead. Where does the shell get it? From your password manager. Most password managers have a command-line tool that asks you to unlock the vault and prints one secret. vault.py is the lesson’s replacement for that tool. It prints the fake key, and the lesson runs without a password manager. The scanner never lists vault.py, because the script assembles the key from parts at run time and the whole string is in no file.

Example · run it

Start the agent with the key in the environment

Section titled “Start the agent with the key in the environment”

Set the variable for this one command from the vault’s output, and start the agent.

Terminal window
AGENT_API_KEY="$(python3 vault.py)" python3 agent.py
Output
key sk-fake-...c0de from environment variable AGENT_API_KEY
deny rules: none

Output verified in CI from site/examples/coding-with-agents/keys-and-secrets/key_from_environment.py.

The agent works the same, and no file in the repository holds the key. For Claude Code, the variable is ANTHROPIC_API_KEY. When it is set there is no browser login: Claude Code shows the key it found and asks you to confirm it [2]. Create that key as a personal key in the Claude Console and store it in the password manager the moment the console shows it. A personal key acts as you and stops working when you leave the organization. A shared key acts as one person for everyone who holds it, and breaks for everyone when that person leaves [3].

Setting the variable in your shell’s startup file (.zshrc, .bashrc) puts the key in another plain-text file, and every program you start can read it. The line above asks the vault each time instead. That is one more prompt per session, and it is the step that keeps the key out of any file.

Step two: a key helper, and deny rules for the files that stay

Section titled “Step two: a key helper, and deny rules for the files that stay”

The second edit removes the prompt without bringing the file back. Instead of you fetching the key and handing it over, the agent runs the vault command itself at start. Edit .agent/settings.json to this:

{
"keyHelper": "python3 vault.py",
"permissions": {
"deny": ["Read(./.env)", "Edit(./.agent/settings.json)"]
}
}
Example · run it

Start the agent with nothing set in the shell. It runs the helper and reports the deny rules it loaded.

Terminal window
python3 agent.py
Output
key sk-fake-...c0de from keyHelper command: python3 vault.py
deny rules: Read(./.env), Edit(./.agent/settings.json)

Output verified in CI from site/examples/coding-with-agents/keys-and-secrets/key_from_helper.py.

In Claude Code the setting is apiKeyHelper: a shell command that returns the key, run at start and again after five minutes by default [2]. The settings file then holds the name of a command, which is safe to commit, and the password manager holds the key.

The deny rules cover the secrets that stay in the repository because the application needs them. .env is the usual home of a database password or a mail token, and a coding agent reads it as readily as any other file. A Read(./.env) deny rule blocks Claude Code’s file tools from opening it, and the vendor’s documentation names one limit that matters here: the rule covers the built-in tools and file commands the tool recognizes, such as cat and head, and doesn’t cover a script that opens the file itself. For enforcement that holds against every process, the same page points at the sandbox, which a later lesson in this course covers [4]. The Edit rule on the settings file keeps the agent from changing its own deny list. Claude Code already treats a write under .claude/ as a protected path that the normal modes prompt for, and an Edit(./.claude/settings.json) deny rule is the explicit form of the same decision [5]. The fixture agent prints the rules and doesn’t enforce them, because enforcement is the real tool’s job.

A .gitignore entry does a different job. It keeps .env out of commits, and it does nothing about the agent reading the file, because the agent reads the working directory and not the repository history.

Checkpoint · sort

You moved the key by hand, and a hand is what forgets a file. A secret scanner is a program that reads every file for strings that look like a key or a password and reports each one with a file name and a line number. Real scanners know hundreds of vendor formats and check git history as well. gitleaks is one, and it is free software [6]. scan.py is the lesson’s replacement, with the fixture’s fake formats and the two patterns every scanner starts from: a password assignment, and a long random-looking string after a key-like prefix.

Example · run it

Run the scanner again.

Terminal window
python3 scan.py
Output
.env:1  password assignment
.env:2  fake token
transcript.txt:4  fake key
3 finding(s)

Output verified in CI from site/examples/coding-with-agents/keys-and-secrets/scan_after_move.py.

Read the report. The settings file no longer appears, which is what you set out to do. .env appears twice, as expected, and it is why the deny rule exists. The third line is the one to stop at: transcript.txt holds the key. In an earlier session someone asked the agent how it was configured, the agent read its settings file and quoted the key in its answer, and the transcript kept it. A transcript is a file like any other. It may end up in a log directory, or someone may paste it into a bug report. From this point, treat that key as exposed.

Checkpoint · choice

A colleague says: “The .env is in .gitignore, so the agent can’t leak it.” Is that right?

The transcript question has one answer: revoke first, investigate second. A leaked key is used quickly, and rotation is cheap. In the Claude Console the keys page has a Disable action that is reversible and a Delete action that is permanent, and the vendor’s own advice for a key you no longer trust is to turn it off or remove it [3]. Create a new key, store it in the password manager, and start the agent again through the helper. Nothing else in your setup changes, because nothing else held the key. Then work out how the leak happened, so it happens once.

The same page suggests an expiry date when you create a key. A key that expires in thirty days limits how long a leaked copy keeps working, and it still isn’t a substitute for keeping it out of files [3].

Checkpoint · scenario

The scanner finds the key in the transcript

Section titled “The scanner finds the key in the transcript”

The scanner reports transcript.txt:4 fake key. In your own setup that line names a real key that is still active. What do you do first?

Exercise

In a copy of the fixture, do the two edits yourself: remove the env block from .agent/settings.json, put "keyHelper": "python3 vault.py" and the two deny rules in its place, and start the agent with python3 agent.py. Then run python3 scan.py and write down, for each line of the report, what you do about it. Plan on five minutes. This is the whole procedure, and after one pass you can repeat it on your real agent’s settings file without the page.

A good result: the agent reports the key from the helper and both deny rules, the scanner reports three findings and none of them in the settings file, and your notes say “deny rule, stays” for the two .env lines and “revoke, then find out how” for the transcript. Then answer one question: which file in your own projects plays the part of transcript.txt?

Stretch: If you have a password manager with a command-line tool, replace vault.py in the keyHelper with the real command that prints a test entry you create for this purpose, and confirm the agent starts. Then delete the test entry.

Recap

  1. A coding agent’s key is billed to you. Use a personal key, never a shared one, and keep it in a password manager [3].
  2. A key never goes in a file the agent can read and commit. The shared settings file is both. Hand the key over through an environment variable set from the password manager, or let a key helper fetch it at start [2].
  3. Deny the agent’s reads of the secrets that stay in the repository, such as .env. .gitignore keeps a file out of commits and does nothing about the agent reading it [4].
  4. Run a secret scanner over the working directory and the transcript, and read every line of the report.
  5. A key that appears where it shouldn’t is revoked first and investigated second [3].

You can now

  • Runs a session from setup to a reviewed diff

  1. Anthropic. Settings files and precedence. Claude Code documentation. Reference. Claude Code settings
  2. Anthropic. Authentication. Claude Code documentation. Reference. Claude Code authentication
  3. Anthropic. Authentication. Claude Platform documentation. Reference. Claude docs authentication
  4. Anthropic. Configure permissions. Claude Code documentation. Reference. Claude Code permissions
  5. Anthropic. Choose a permission mode. Claude Code documentation. Reference. Claude Code permission modes
  6. Zachary Rice and contributors. Gitleaks. GitHub, gitleaks/gitleaks README. Reference. Gitleaks