Sealed Credentials¶
Sealed credentials let agents carry their own encrypted API keys. Wardgate decrypts them at proxy time and forwards the real values to upstream services. Agents never see the plaintext - even if they dump the encrypted values, those are useless without the seal key (which lives only on the Wardgate server).
When to Use¶
Static credentials (credential_env) work well when the operator controls all upstream API keys centrally. Sealed credentials are better when:
- Agents need individual API keys for the same service (e.g., per-agent GitHub tokens)
- You're running agents in sandboxed environments (conclaves) and don't want to manage every upstream key on the server
- You want the agent to control which headers reach upstream (any auth scheme: Bearer, API key, Basic, custom headers)
How It Works¶
The operator encrypts upstream credentials using a shared seal key. The encrypted values are given to agents (e.g., as environment variables in their sandbox). Agents send them as X-Wardgate-Sealed-* prefixed headers. Wardgate strips the prefix, decrypts, and forwards.
Agent sends:
Authorization: Bearer <jwt-for-wardgate-auth>
X-Wardgate-Sealed-Authorization: <encrypt("Bearer ghp_realtoken")>
X-Wardgate-Sealed-X-Api-Key: <encrypt("key_12345")>
Wardgate processes:
1. JWT auth validates agent identity
2. Strip "X-Wardgate-Sealed-" prefix -> Authorization, X-Api-Key
3. Decrypt value -> "Bearer ghp_realtoken", "key_12345"
4. Strip agent's Authorization header (it's for Wardgate, not upstream)
Upstream receives:
Authorization: Bearer ghp_realtoken
X-Api-Key: key_12345
No mapping config needed. The agent is in full control of what headers reach upstream.
Setup¶
1. Generate a seal key¶
# Generate a 32-byte hex-encoded AES-256 key
export WARDGATE_SEAL_KEY=$(openssl rand -hex 32)
# Add to your .env file
echo "WARDGATE_SEAL_KEY=$WARDGATE_SEAL_KEY" >> .env
2. Configure the server¶
server:
listen: :8080
jwt:
secret_env: JWT_SECRET
seal:
key_env: WARDGATE_SEAL_KEY # 32-byte hex-encoded AES-256 key
endpoints:
github:
upstream: https://api.github.com
auth:
sealed: true # credentials come from agent's sealed headers
rules:
- match: { method: GET }
action: allow
- match: { method: POST, path: "/repos/*/issues" }
action: allow
- match: { method: "*" }
action: deny
When sealed: true:
- type and credential_env are not required (the agent provides credentials)
- credential_env and sealed are mutually exclusive -- you cannot set both
- server.seal must be configured or validation fails
- All policy evaluation (rules, grants, rate limits) still applies normally
Header Whitelist (Optional)¶
By default, only common authentication headers are allowed to be sealed:
- Authorization
- X-Api-Key
- X-Auth-Token
- Proxy-Authorization
To allow additional headers, configure allowed_headers:
server:
seal:
key_env: WARDGATE_SEAL_KEY
allowed_headers:
- Authorization
- X-Api-Key
- X-Custom-Header # Allow custom headers if needed
Headers not in the whitelist will be rejected to prevent agents from sealing sensitive headers like Host or Cookie.
3. Encrypt credentials for agents¶
# Encrypt an upstream API token
wardgate seal "Bearer ghp_agent1_github_token"
# Output: c2VhbGVkX1...base64...
# Encrypt an API key
wardgate seal "key_12345"
# Output: YWJjZGVm...base64...
The seal command reads WARDGATE_SEAL_KEY from the environment (or .env file).
4. Give encrypted values to agents¶
Inject the encrypted values as environment variables in the agent's sandbox:
5. Agent makes requests¶
The agent prefixes each upstream header name with X-Wardgate-Sealed-:
wardgate-cli \
-H "X-Wardgate-Sealed-Authorization: $GITHUB_SEALED" \
https://api.github.com/repos/owner/repo/issues
Configuration Reference¶
server.seal¶
| Field | Type | Default | Description |
|---|---|---|---|
key_env |
string | (required) | Environment variable holding the 32-byte hex-encoded AES-256 key |
cache_size |
int | 1000 |
Maximum number of entries in the decryption LRU cache |
allowed_headers |
[]string | ["Authorization", "X-Api-Key", "X-Auth-Token", "Proxy-Authorization"] |
Whitelist of header names that can be sealed. If empty, defaults to common auth headers. |
endpoints.auth.sealed¶
| Field | Type | Default | Description |
|---|---|---|---|
sealed |
bool | false |
When true, credentials come from agent's X-Wardgate-Sealed-* headers |
Encryption Details¶
- Algorithm: AES-256-GCM (authenticated encryption from Go standard library)
- Key: 32 bytes, stored as hex in an environment variable
- Sealed format:
base64(12-byte-nonce || ciphertext || GCM-tag) - No new dependencies: uses
crypto/aesandcrypto/cipherfrom the Go standard library
Each encryption produces a unique ciphertext (random nonce), so encrypting the same value twice yields different outputs. Decryption is deterministic.
Decryption Cache¶
To avoid repeated AES-GCM decryption for the same sealed values across requests, Wardgate caches results in a fixed-size LRU (least recently used) cache.
- Key: the sealed ciphertext string (same ciphertext always maps to the same plaintext)
- Eviction: when the cache is full, the least recently used entry is evicted
- Capacity: configurable via
cache_size(default: 1000 entries) - Thread safety: mutex-protected for concurrent access
The cache is purely a performance optimization. Evicted entries are re-decrypted on the next request. There is no TTL -- the ciphertext-to-plaintext mapping is deterministic, so cached entries never become stale.
Mixing Static and Sealed Endpoints¶
Sealed credentials are configured per-endpoint. Existing static credential endpoints continue to work unchanged:
endpoints:
# Sealed: agent provides encrypted credentials
github:
upstream: https://api.github.com
auth:
sealed: true
rules: [...]
# Static: Wardgate injects credential from vault
todoist:
upstream: https://api.todoist.com
auth:
type: bearer
credential_env: TODOIST_TOKEN
rules: [...]
Multiple Sealed Headers¶
The agent can send multiple X-Wardgate-Sealed-* headers in a single request. Each is independently decrypted and forwarded:
wardgate-cli \
-H "X-Wardgate-Sealed-Authorization: $AUTH_SEALED" \
-H "X-Wardgate-Sealed-X-Api-Key: $KEY_SEALED" \
-H "X-Wardgate-Sealed-X-Custom-Header: $CUSTOM_SEALED" \
https://api.example.com/data
Error Handling¶
| Condition | Response |
|---|---|
No X-Wardgate-Sealed-* headers on a sealed endpoint |
400 Bad Request |
| Invalid base64 encoding | Header is skipped (logged) |
| Tampered or invalid ciphertext | Header is skipped (logged) |
| Wrong seal key | Decryption fails, header is skipped |
Security Considerations¶
- The seal key must be kept secret. It should only exist on the Wardgate server (in the
.envfile or process environment). - Encrypted values are safe to give to agents -- they cannot be decrypted without the seal key.
- All policy rules, rate limits, grants, and approval workflows still apply to sealed endpoints.
- Sealed headers are stripped before forwarding to upstream -- the upstream never sees
X-Wardgate-Sealed-*headers. - Non-sealed headers (e.g.,
Content-Type,Accept) are passed through to upstream unchanged. - Sealed values have no replay protection -- the same encrypted value can be used across multiple requests. This is by design, as sealed credentials represent long-lived API keys that agents reuse. Treat sealed values with the same care as the plaintext credentials they contain.