An AI agent is software that holds your credentials and acts on them autonomously. It reads your LLM provider key, writes to your database, pushes to your repos, and sends messages as you. A leaked secret in a normal web app is a config bug. In an agent, it's an attacker that already knows exactly how to use the key — because the agent's whole job is to use keys. This tutorial locks secrets down across the entire agent stack: Git keeps them out of history, Docker injects them at runtime, sun-port encrypts them in transit, and PostgreSQL records who rotated what and when — all wired into the Hermes and im-bot agent runtimes this site documents.
A traditional service has a handful of secrets, loaded once at boot, used over a narrow, well-understood surface. An agent is different in four ways:
In a multi-agent system like im-bot, the blast radius grows again: agents share rooms, share a database, and share a message bus. One leaked connector key can let an attacker impersonate every agent in the room.
Anything that grants access, proves identity, or decrypts data is a secret. For a typical agent stack the inventory looks like this:
Not every piece of config is a secret. A database hostname, a model name, or a callback URL is fine in a repo. The rule of thumb: if revealing it to an attacker changes your security posture, it's a secret.
Before picking tools, name the four vectors a secret can leak through. Everything below maps to one of these:
HEAD..env files, Docker layers, and database dumps sitting on disk or in backups.Your defense is defense-in-depth: no single control is enough, but together they make each vector independently hard to exploit.
The cheapest, highest-value control: a pre-commit hook that refuses to let a secret enter history in the first place. gitleaks scans staged changes for entropy-based and pattern-based matches against 100+ provider signatures:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.4
hooks:
- id: gitleaks
$ pip install pre-commit
$ pre-commit install
$ git add . && git commit -m "add agent loop"
gitleaks.............................................................Failed
- hook id: gitleaks
- exit code: 1
Finding: generic-api-key
Secret: sk-abc123...
File: .env
Commit: (current)
Layer a .gitignore on top so credential files can't even be staged accidentally:
# .gitignore
.env
.env.*
!.env.example
secrets/
*.pem
*.key
The .env.example exception is important — commit a template with placeholder values, never real ones.
git filter-repo (or BFG), force-push, and rotate the key immediately. A key that ever touched history is burned — assume an attacker has already scanned for it.The twelve-factor rule holds for agents: configuration lives in the environment, not in code. Load secrets at runtime so the same image can run against different credentials in dev and prod:
$ export OPENAI_API_KEY="sk-your-key-here"
$ export POSTGRES_PASSWORD="change-me"
$ export JWT_SECRET="$(openssl rand -hex 32)"
$ ./agent run
Two rules make this safe:
Dockerfile, or baked into an image layer.If you must keep a local .env for development, lock it down:
$ chmod 600 .env
Docker has two mechanisms worth using, and one trap that bites almost everyone. The right tool for real secrets is Compose secrets — the value is mounted into the container as a file, so it never appears in docker inspect or the process list:
# docker-compose.yml
services:
agent:
image: ghcr.io/example/agent:latest
secrets:
- openai_api_key
- pg_password
environment:
OPENAI_API_KEY_FILE: /run/secrets/openai_api_key
POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
db:
image: postgres:16
secrets:
- pg_password
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
secrets:
openai_api_key:
file: ./secrets/openai_api_key.txt
pg_password:
file: ./secrets/pg_password.txt
Your app reads the secret from the file instead of the environment, so the plaintext never sits in a shell or a docker inspect dump.
docker run --env-file reads the file only at container creation. Editing .env and running docker restart does not change a running container's environment. To apply a rotated secret you must docker rm -f and docker run again — a recreate, not a restart. This is the single most common cause of "I rotated the key but the old one still works."For a full breakdown of the recreate pattern and how to prove the new value landed, see our Docker deployment tutorial.
The newest and least-obvious leak surface is the model itself. A prompt-injection attack hides instructions in tool output — a web page the agent reads, a file it opens, a message it receives. If your agent's context contains a live API key, an injected instruction can persuade it to "confirm the key is still valid by echoing it." The fix is architectural:
*** in the stored log even when the runtime value is intact.Treat the context window like a log file that a third party (the model provider) can read — because effectively, that's what it is.
A secret is only as safe as the channel it crosses. If your agent POSTs a credential over plaintext HTTP, anyone on the path can read it. sun-port — the Cloudflare Pingora-based reverse proxy we covered earlier — terminates TLS at the edge, so internal services only ever speak plaintext to localhost:
# Internal services bind to loopback only — never exposed to the internet
$ ss -tlnp | grep -E '5432|8000'
LISTEN 0 128 127.0.0.1:5432 0.0.0.0:* (postgres)
LISTEN 0 128 127.0.0.1:8000 0.0.0.0:* (agent-api)
The rule: bind everything to 127.0.0.1, expose nothing but sun-port. The proxy holds the one public-facing TLS cert; everything behind it is loopback-only, so a credential in transit never leaves the host unencrypted.
PostgreSQL's job in this design is not to hold raw secrets — a database is a poor place to keep a credential you'll need to inject at boot. Instead, use it for the two things a secret-management system actually needs: configuration pointers and an audit trail.
Store where a secret lives and its lifecycle, not the secret itself:
-- secrets.sql — pointers and rotation audit, never the raw values
CREATE TABLE IF NOT EXISTS secret_registry (
name TEXT PRIMARY KEY, -- 'openai_api_key', 'pg_password'
kind TEXT NOT NULL, -- 'env' | 'compose-secret' | 'vault'
location TEXT NOT NULL, -- '/run/secrets/openai_api_key'
owner TEXT NOT NULL, -- 'agent', 'im-bot', 'infra'
rotated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS secret_events (
id BIGSERIAL PRIMARY KEY,
secret_name TEXT NOT NULL REFERENCES secret_registry(name),
event TEXT NOT NULL, -- 'created' | 'rotated' | 'revoked'
actor TEXT, -- which agent or operator did it
at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Now you can answer "who rotated the database password, and when?" with a query instead of a Slack archaeology expedition:
$ sudo -u postgres psql -d agent_rule -c \
"SELECT secret_name, event, actor, at FROM secret_events ORDER BY at DESC LIMIT 10;"
secret_name | event | actor | at
-----------------+---------+----------+---------------------
pg_password | rotated | hermes | 2026-08-15 09:00:00
openai_api_key | created | susu | 2026-08-14 18:12:00
pgcrypto's pgp_sym_encrypt and keep the passphrase in a Compose secret, not in the schema. Plaintext credentials in a table are a gift to anyone who dumps the DB.im-bot connects to external platforms — Telegram, Discord, Slack, and more — through connectors, and each connector carries its own bot token or API key. The same discipline applies, with one extra rule specific to multi-agent systems: scope credentials per connector, not per room. If two agents in the same room share a Telegram bot, they share one token; don't mint one token per agent that's actually interchangeable, and don't paste the connector token into the room's shared context where every agent (and any prompt injection) can read it.
# im-bot connector config — token via env/file, never inline in the room
connectors:
telegram:
enabled: true
token_file: /run/secrets/telegram_bot_token
discord:
enabled: true
token_file: /run/secrets/discord_bot_token
The token file is mounted by Compose secrets (Section 6), so the connector reads its credential at startup and the model never touches it.
Secrets rot. Rotate on a schedule, on any suspected leak, and on any personnel or agent-permission change. Rotation is where the whole pipeline comes together — and where the env-file recreate trap from Section 6 matters most:
openssl rand -hex 32.docker compose up -d --force-recreate (or docker rm -f + docker run).secret_events row so the audit trail reflects it.Schedule step 1–4 as a Hermes cron job — the same scheduler we covered in our cron automation tutorial — so rotation happens on a calendar, not when someone remembers.
Secrets work is unverifiable if you can't see it. Prove three things before you trust the setup:
# 1. History is clean — gitleaks finds nothing in any commit
$ gitleaks detect --source . --report-format json --report-path /dev/null
0 leaks found
# 2. The running container got the NEW value (recreate worked), without printing it
$ docker exec agent sh -c 'echo ${OPENAI_API_KEY_FILE:+set}'
set
$ docker exec agent sh -c 'cat /run/secrets/openai_api_key | wc -c'
52
# 3. Nothing secret is reachable over plaintext from the outside
$ curl -s -o /dev/null -w '%{http_code}' http://your-host:5432
000 # connection refused — DB is loopback-only
Note the verification style: check that the secret is present and the right length, without ever printing the value into a log. A credential that only ever lives in a file you never echo is a credential that can't leak through your own tooling.
docker restart doesn't reload --env-file — the #1 rotation failure. Recreate, don't restart.COPY'd in the Dockerfile or baked by a RUN step is in the image forever. Use Compose secrets or runtime env only..env in backups — a DB dump or server snapshot that includes .env leaks everything. Exclude it from backups or encrypt the backup.COPY'd into the image, never pasted into docker inspect.Secrets management for agents isn't a feature you bolt on — it's a property of the architecture. Git guards the source, Docker guards the runtime, sun-port guards the wire, PostgreSQL guards the history, and the runtime guards the prompt. Get all five, and a leaked key stops being a catastrophe and becomes a routine rotation.