A secrets firewall for LLM agents. Aegis is an MCP server that lets a model make HTTP requests to your APIs β without ever letting it see the credentials those requests are authenticated with.
To let an agent use an API, you normally hand it the API key β in an
environment variable, an MCP server config, a .env file. From that moment the
key is one prompt injection, one careless log line or one screenshot away from
leaking. The model does not need the key. It needs the result.
Aegis sits in between. The model asks for a request; Aegis decides whether it is allowed, adds the credentials on the way out, strips them on the way back.
βββββββββββ MCP (OAuth 2.1) βββββββββββββ HTTPS + secret ββββββββββββ
β LLM β βββββββββββββββββββΊ β aegis β ββββββββββββββββββΊ β API β
β agent β βββββββββββββββββββ β :2019 β ββββββββββββββββββ β β
βββββββββββ redacted result βββββββββββββ response ββββββββββββ
β
config.yaml
users Β· targets Β· secrets
- Language: Go (standard library, plus
gopkg.in/yaml.v3andx/crypto) - Transport: MCP over Streamable HTTP on port
2019 - Auth: OAuth 2.1 with Dynamic Client Registration; Aegis is its own authorization server with a login screen
- Config: a single
config.yaml, hot-reloaded - State: in memory only β nothing is persisted, ever
The image is published to two registries from the same build, with the same digest and the same tags. Neither is a mirror of the other; pick whichever you already trust.
| Registry | Image |
|---|---|
| GitHub Container Registry | ghcr.io/andreaskasper/aegis |
| Docker Hub | andreaskasper/aegis |
linux/amd64 and linux/arm64, with an SBOM and a signed
build provenance attestation
on every release.
The examples below use the ghcr.io address throughout β a compose file can only
pull one, and printing both would make them ambiguous rather than helpful.
Substitute andreaskasper/aegis anywhere you see it if you prefer Docker Hub.
docker run -d --name aegis -p 2019:2019 \
-v "$PWD/config.yaml:/etc/aegis/config.yaml:ro" \
-e AEGIS_PUBLIC_URL=https://aegis.example.com \
ghcr.io/andreaskasper/aegis:latestOr with Compose:
services:
aegis:
# or: andreaskasper/aegis:latest
image: ghcr.io/andreaskasper/aegis:latest
ports: ["2019:2019"]
volumes:
- ./config.yaml:/etc/aegis/config.yaml:ro
environment:
AEGIS_PUBLIC_URL: https://aegis.example.com
restart: unless-stoppedThen point an MCP client at https://aegis.example.com/mcp. The client
discovers the OAuth endpoints, registers itself, opens the login page in a
browser, and you sign in with a user from your config.yaml.
Aegis speaks plain HTTP and does not terminate TLS. Put it behind a reverse proxy (Traefik, Caddy, nginx, Cloudflare) in production.
| Tag | Points at |
|---|---|
latest |
the most recent release |
0.1.6 |
that exact release |
0.1 |
the newest patch of that minor |
edge |
current main, rebuilt on every commit |
sha-1a2b3c4 |
one exact commit |
edge is built from every push to main. It is where a fix lands first, and
also where a mistake lands first β pin a release for anything you care about.
Everything lives in one YAML file. Users are the top-level unit: each user brings their own secrets and their own targets.
server:
listen: ":2019"
public_url: "https://aegis.example.com" # or AEGIS_PUBLIC_URL
max_response_bytes: 1048576 # 1 MiB
token_ttl: "12h"
users:
- name: andreas
password: "bcrypt:$2a$12$Xk8f...redacted..."
allow_any: false
secrets:
lexware_token: "env:LEXWARE_TOKEN"
github_pat: "file:/run/secrets/github_pat"
weather_key: "abcdef123456"
targets:
- id: lexware
description: "Lexware Office accounting API β invoices, contacts, vouchers"
base_url: "https://api.lexware.io"
methods: [GET, POST]
paths: ["/v1/**"]
rate_limit: "60/m"
inject:
headers:
Authorization: "Bearer ${lexware_token}"
- id: github
description: "GitHub REST API, read-only"
base_url: "https://github.com/ghapi"
methods: [GET]
paths: ["/repos/andreaskasper/**", "/user"]
rate_limit: "120/m"
inject:
headers:
Authorization: "Bearer ${github_pat}"
Accept: "application/vnd.github+json"
- id: weather
description: "OpenWeatherMap current conditions"
base_url: "https://api.openweathermap.org"
methods: [GET]
paths: ["/data/2.5/**"]
inject:
query:
appid: "${weather_key}"Any secret value β and any password β may be written literally or as a reference:
| Form | Meaning |
|---|---|
hunter2 |
literal value |
env:NAME |
read from the environment |
file:/path |
read from a file (Docker/Podman secrets) |
bcrypt:$2a$12$β¦ |
passwords only: a bcrypt hash |
Generate a hash with docker run --rm ghcr.io/andreaskasper/aegis hashpw.
Aegis watches config.yaml and reloads it when it changes; SIGHUP forces a
reload. If the new file does not parse or does not validate, the old
configuration stays active and the error is logged. Existing sessions survive a
reload.
Aegis is a full OAuth 2.1 authorization server, so a compliant MCP client needs nothing but the URL:
| Endpoint | Purpose |
|---|---|
/.well-known/oauth-protected-resource |
points at the authorization server |
/.well-known/oauth-authorization-server |
endpoint + capability metadata |
POST /register |
dynamic client registration |
GET /authorize |
login form |
POST /authorize |
credential check β auth code |
POST /token |
code (PKCE, S256) β access token |
The login form asks for a username and password from config.yaml. There is no
consent screen: a successful login issues a token scoped to that user's
targets. The token is the identity β it decides which targets may be reached
and which secrets get injected.
Everything is in memory. Registered clients, authorization codes and access
tokens do not survive a restart. After docker restart aegis, clients
re-register and users sign in again. This is deliberate: a container holding
credentials should leave nothing behind on disk.
Aegis exposes exactly two tools. A small surface is the point.
No arguments. Returns the targets the calling user may reach β id, description, base URL, allowed methods and path patterns. The model needs this to know what it can even attempt. Secrets and injection rules are not part of the response.
{
"targets": [
{
"id": "github",
"description": "GitHub REST API, read-only",
"base_url": "https://github.com/ghapi",
"methods": ["GET"],
"paths": ["/repos/andreaskasper/**", "/user"]
}
]
}| Argument | Type | Notes |
|---|---|---|
url |
string | required, absolute |
method |
string | default GET |
headers |
object | optional; reserved headers are rejected |
query |
object | optional |
body |
string | optional |
Returns status, response headers and body β after redaction and truncation.
{
"status": 200,
"headers": {"content-type": "application/json"},
"body": "{\"login\":\"andreaskasper\"}",
"truncated": false,
"target": "github",
"duration_ms": 143
}- Authenticate the bearer token β resolves to exactly one user.
- Match the URL against that user's targets: host, path pattern, method.
No match β
403, and nothing leaves the container. Unless the user hasallow_any: true, in which case any public host is permitted β but private ranges, loopback and cloud metadata endpoints stay blocked, always. - Rate-limit per user and target.
- Inject the declared headers, query parameters and body fields, resolving
${secret}references. The model cannot influence this step and never sees the result. - Send the request. Redirects are not followed automatically.
- Redact the response: every one of the user's secret values is searched
for in the body and headers and replaced with
[REDACTED:name].Set-Cookieis dropped. - Truncate to
max_response_bytesand flag it. - Log one JSON line to stdout.
- Deny by default. A URL that matches no target is never fetched.
- SSRF guards.
127.0.0.0/8,10/8,172.16/12,192.168/16,169.254/16,::1,fc00::/7and DNS names resolving into them are refused β including on redirects and including forallow_anyusers. - Secrets are one-way. They travel outbound only. No tool, error message or log line returns a secret value; the audit log records secret names.
- Reflection is caught. An API that echoes your token back β in a debug endpoint, in an error message β cannot leak it into the model's context, because the response is scanned for it.
- Header allowlist. The model may not set
Authorization,Cookie,Host,X-Forwarded-*or any header a target injects. - Constant-time comparison for tokens and passwords.
- No persistence. Nothing is written to disk, so nothing can be read off it.
Aegis reduces the blast radius of a compromised or manipulated agent. It does
not make one safe: a POST-enabled target can still be used to do damage
within what you allowed. Scope your targets to what the agent actually needs.
One JSON line per request on stdout:
{"ts":"2026-07-28T21:14:02Z","user":"andreas","target":"github",
"method":"GET","url":"https://github.com/ghapi/user","status":200,
"duration_ms":143,"bytes":312,"truncated":false,
"injected":["github_pat"],"redacted":0}injected lists secret names. Values never appear β not here, not anywhere.
.
βββ Dockerfile
βββ .dockerignore
βββ docker-compose.yml
βββ config.example.yaml
βββ README.md
βββ .docker/README.md # the Docker Hub overview
βββ projektbeschreibung.md # what and why
βββ spezifikation.md # the full spec
βββ src/
βββ go.mod
βββ main.go # entry point, routing, graceful shutdown
βββ config.go # YAML schema, validation, value resolution
βββ reload.go # file watcher + SIGHUP
βββ oauth.go # discovery, DCR, /authorize, /token
βββ login.go # login form + password verification
βββ session.go # in-memory clients, codes, tokens
βββ mcp.go # Streamable HTTP transport, JSON-RPC
βββ tools.go # list_targets, http_request
βββ match.go # target matching, path globs, SSRF guard
βββ inject.go # secret injection
βββ redact.go # response redaction + truncation
βββ ratelimit.go # per user/target limiter
βββ audit.go # structured log
Contributions are welcome! Feel free to open an issue or submit a Pull Request.
MIT License β feel free to use this in your own projects!
If this project saves you time, consider supporting its development:
Made with β€οΈ by Andreas Kasper