SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Keep secrets out of your AI agents

An agent holding your API keys can leak them in one tool call. Give it scoped short lived tokens behind a credential gateway, never the real keys.

What keeping secrets out of AI agents means

An AI agent is a normal Linux process that runs commands. Every environment variable that process holds is readable by the code it runs, so an API key in the agent's environment is a key the agent can send to any host it can reach. Keeping secrets out of the agent means giving it a handle instead of the key: a short lived scoped token, or a placeholder that something else swaps for the real value at the network boundary.

This is not a story about a model turning hostile. The mechanism is duller. An agent reads a web page, a README, or an issue comment that contains instructions, and it follows them, because to a language model there is no difference between text you wrote and text it fetched. That is prompt injection. Once it happens, the damage is bounded by exactly one thing: what the process can read. If you have not set a boundary yet, running a coding agent safely on a server covers the isolation rungs this guide sits on top of.

The threat model in plain terms

Run this as the user your agent runs as.

tr '\0' '\n' < /proc/self/environ | grep -iE 'key|token|secret|password'

Every line it prints is one HTTP request away from a stranger's server. Now look at what is on disk near the agent.

grep -rIl --exclude-dir=.git -e 'API_KEY' -e 'SECRET' ~/projects
find ~ -maxdepth 3 -name '.env' -o -name 'credentials' -o -name '*.pem'

An agent with a shell does not need a clever exploit to move that data out. Four ordinary paths do the job, and all four look like normal work in the log:

  • An outbound curl or fetch to any host, with the value in a query string.
  • A git commit and git push to a repository the agent can write.
  • A package install script, which runs arbitrary code as the agent's user.
  • A DNS lookup of a hostname that contains the value, which leaves even when HTTP egress is blocked.

You cannot review your way out of this. The fix is to make sure there is nothing valuable in reach.

A secret in the working tree is a secret in the context window

An agent reads files. A .env file in the repository it is working in will be read, and once read it is in the context window, which means it is in the transcript, in any log you keep, and in whatever the agent writes next.

Before, with the key sitting in the tree the agent works in:

cd ~/projects/billing
cat .env
# STRIPE_SECRET_KEY=sk_live_...
# DATABASE_URL=postgres://app:hunter2@db.internal:5432/billing

After, with the file moved out of reach:

sudo install -d -m 750 -o root -g agent-review /etc/agent-review
sudo install -m 640 -o root -g agent-review ~/projects/billing/.env /etc/agent-review/billing.env
rm ~/projects/billing/.env

The agent's user can no longer open the file, because the working tree no longer contains it. Deny rules in the agent's own config are a second layer, not the first one. Claude Code reads permission rules from .claude/settings.json in the project:

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

That stops the honest mistake of the agent opening a file while exploring, and it reaches past the file tools: Claude Code also checks these rules against file commands it recognizes in Bash, such as cat and head, and against the targets of redirections like < .env. It does not stop an injected instruction from running grep -r KEY . inside the project directory, or a short Python script that opens .env itself, because the permissions documentation says deny rules do not apply to commands that read files without naming them or to subprocesses that open files on their own. For those, the docs point to Claude Code's sandbox, which enforces the block at the operating system level. Whether you are asked before such a command runs depends on the session's permission mode, and auto mode is now the built-in starting mode for interactive Claude Code sessions, so a server you are not watching will run more of those commands unprompted. The same limit applies to anything that shapes the agent's habits rather than its permissions: a skill that holds the agent to the smallest change that works keeps a run from wandering into files it had no business opening, but it is still advice the model can be talked out of. Treat the config as a guardrail and the filesystem permission as the wall. The same split applies inside containers: env files and secrets in Docker Compose covers the version of this problem one layer down.

Give every agent its own unprivileged user

If the agent runs as you, it inherits your SSH keys, your cloud credentials, and your shell history. A separate user costs one command and removes all of that.

sudo adduser --disabled-password --gecos "" agent-review
sudo chmod 700 /home/agent-review
sudo -u agent-review cat ~/.ssh/id_ed25519

The last line must fail with cat: /home/you/.ssh/id_ed25519: Permission denied. If it prints a key instead, the agent's user can reach the file. Opening a file by name needs search (x) permission on every directory above it, not read permission, so a home directory at mode 711 that nobody else can list still lets the key through if the key file itself is readable. chmod 700 ~ closes the path, and chmod 600 ~/.ssh/id_ed25519 fixes the key. Do not add the agent user to sudo, and do not give it a NOPASSWD rule broader than the one command it truly needs. Least privilege users on a VPS goes through the group and sudoers detail. Keep that separation in mind once you run more than one session on the box, because one Claude Code session can send text straight to another, and whatever the first session holds can cross that channel in a single message.

One more boundary is worth adding on a cloud VPS. The instance metadata service answers on a fixed link local address, and it often hands out role credentials to anything that asks. Nothing at the endpoint checks which local user is calling: on EC2, AWS documents that IMDSv1 calls need no token, and its own advice for keeping one user away from the service is a local firewall rule keyed on that user's ID.

sudo iptables -I OUTPUT 1 -p tcp -m owner --uid-owner agent-review -d 169.254.169.254 -j REJECT

Insert the rule at the top with -I rather than appending it with -A. On a host running ufw with its default allow outgoing policy, ufw's last OUTPUT chain accepts every new TCP connection, so an appended rule is never reached. iptables rules also disappear at reboot unless you persist them, and if you have enabled the metadata service's IPv6 endpoint, [fd00:ec2::254], add the same rule with ip6tables.

Check it from the agent's side. sudo -u agent-review curl -s --max-time 3 http://169.254.169.254/ should print nothing and exit non zero, because the packet is rejected before it leaves the box. If it prints a listing, the rule is not being reached.

Inject the credential at the boundary

The pattern that actually solves this is credential injection. The agent never holds a real key. It sends its request through a local gateway, and the gateway swaps a placeholder for the real secret on the way out. The secret lives in the gateway's storage, in a different process, owned by a different user.

OneCLI is one open source implementation of this. It is Apache-2.0 licensed apart from its ee/ directories, which carry a separate enterprise license that requires a subscription for production use, and it runs in containers next to the agent. As of October 2026 the project documents this Docker Compose setup, which reads three generated secrets from a .env file beside the compose file:

git clone https://github.com/onecli/onecli.git && cd onecli/docker
cat > .env <<EOF
SECRET_ENCRYPTION_KEY=$(head -c 32 /dev/urandom | base64)
GATEWAY_INTERNAL_SECRET=$(head -c 32 /dev/urandom | base64)
BETTER_AUTH_SECRET=$(head -c 32 /dev/urandom | base64)
COMPOSE_PROFILES=runner
EOF
chmod 600 .env
docker compose up -d --wait

Back up that .env file, because losing SECRET_ENCRYPTION_KEY orphans every secret stored with it. The dashboard listens on port 10254 and the gateway on 10255, both bound to 127.0.0.1 by default. You store the real credential once, then give each agent a placeholder value in place of the key plus its own scoped access token, which it sends in a Proxy-Authorization header. The gateway matches the outbound request by host and path, decrypts the matching credential, and substitutes it. The agent's environment holds nothing worth stealing.

The value here is not the encryption. It is that the question "what did this agent use, and when" becomes a log query. You read one audit trail instead of guessing which of six environments held a copy of the key.

Hand the secret to the process, not to the environment

If you run the agent under systemd, you do not need environment variables at all. LoadCredential= places the secret in a private directory that only the service's user can read, exposed as %d in the unit file and as $CREDENTIALS_DIRECTORY inside the process. That restriction is by user, not by service, so give each agent service its own user: any other process running as the same user can open the directory unless the unit also uses filesystem namespacing, such as PrivateMounts=yes, which hides it from other services. The value never appears in /proc/<pid>/environ, so ps eww cannot show it, and the directory disappears when the service stops.

Encrypt the credential to the machine first. These commands come from the systemd documentation and work on systemd 250 or newer, which covers Ubuntu 24.04 and Debian 13:

echo -n 'sk-example-value' > /tmp/plain.txt
sudo systemd-creds encrypt --name=api_key /tmp/plain.txt /etc/credstore/api_key.cred
shred -u /tmp/plain.txt
sudo systemd-run -P --wait -p LoadCredentialEncrypted=api_key:/etc/credstore/api_key.cred \
  systemd-creds cat api_key

The last command prints sk-example-value. That proves the encrypted file decrypts on this host. Then reference it from the unit:

[Service]
User=agent-review
LoadCredentialEncrypted=api_key:/etc/credstore/api_key.cred
Environment=AGENT_KEY_FILE=%d/api_key
ExecStart=/usr/local/bin/agent-worker

Your agent code opens the file at $AGENT_KEY_FILE when it needs the value. A file read is a moment. An environment variable lasts for the life of the process, in every child it spawns.

Prefer short lived tokens to long lived keys

A key that never expires is still valid whenever it surfaces, months later, in a log or a transcript. Where the service offers a session token, take the session token and set the shortest lifetime the work allows.

aws sts assume-role \
  --role-arn arn:aws:iam::123456789012:role/agent-readonly \
  --role-session-name agent-review \
  --duration-seconds 900

Fifteen minutes is the minimum AWS STS (security token service) accepts, and it is usually enough for one agent task. For GitHub, give the agent user its own gh login with a fine grained token scoped to the single repository it works on, so gh auth token inside that session returns something that cannot touch anything else. Scope by resource first, then by time.

Verify, then keep verifying

Three checks are worth running after any change to an agent's setup. Run them as the agent's user, not as yourself.

sudo -u agent-review env | grep -iE 'key|token|secret'
sudo -u agent-review ls -la /home/you/ 2>&1 | head -3
sudo -u agent-review curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 https://api.github.com/user

The first should print nothing at all. The second should print ls: cannot open directory '/home/you/': Permission denied, though that only shows the directory cannot be listed; the cat test earlier is what proves a file inside it cannot be read. The third tells you what identity the agent's network path presents, which is the question the gateway pattern exists to answer: a 401 means the request reached GitHub without a credential GitHub accepts, either none at all or one that is invalid or expired, and a 200 means it is carrying a valid one, so you should know which token it is. If you run agents unattended, controlling AI agent costs on a VPS covers the budget limits that pair with these access limits.

FAQ

Can I just trust the model not to leak my keys?

No, because the model is not the attacker in this threat model. The agent reads text from web pages, repositories, and issue trackers, and that text can contain instructions. The model has no reliable way to tell your instructions from text it fetched. Any control that depends on the model choosing correctly fails the first time an injected instruction is convincing, so the control has to sit in the operating system or the network instead.

Are environment variables really that bad for agent secrets?

They are bad in one specific way: they are inherited. Every child process the agent spawns gets a copy, including a build script, a test runner, and any package install hook. The variables are also readable through /proc/<pid>/environ by the same user, so anything the agent runs can read them without the agent passing them along. A file read at the moment of use, with LoadCredential= or a gateway, limits the exposure to that moment.

Does putting secrets in a vault solve this on its own?

Only partly. A vault fixes storage. If you self host that vault, it needs its own hardening pass, because a Vaultwarden server is usually broken through its admin token or its backup file, not through the encrypted items it holds. It does not fix the last step, where something pulls the secret out of the vault and hands it to the agent as an environment variable, which puts you back where you started. What matters is who performs the substitution. If the agent fetches the secret, the agent has the secret. If a gateway or the init system performs the substitution outside the agent's process, the agent never holds it.

How do I know whether an agent has already leaked something?

Usually you cannot tell after the fact, which is the argument for the gateway. Without one, your evidence is scattered across shell history, the agent's transcript, and outbound connection logs you are probably not keeping. With a credential gateway, every use of a credential is one line with an agent identity and a timestamp. If you suspect a leak, rotate the key first and investigate second. Rotation is cheap and certainty is not.

What is the minimum I should do today?

Move every .env file out of the directories your agents work in, and create one unprivileged user per agent. Those two changes take about ten minutes and close the most common path, which is an agent reading a credential file that had no reason to sit next to the code. The gateway and the short lived tokens are the next step, not the first one. The same starting point applies to any agent runtime, including running an autonomous agent safely on a VPS.

Was this article useful?