Secrets
Every meaningful agent needs credentials — Gmail OAuth tokens, an Apollo API key, an Anthropic key. Secrets management is the part of the system that has to be right from day one, because retrofitting it later means rewriting every skill that touches it.
How secrets are stored
Secrets live in your Postgres database, but the values are never written in plaintext.
secrets id, workspace_id, name, kind, description, created_at
secret_versions id, secret_id, version, ciphertext, nonce, created_at
- The secret row is a stable handle (name, kind, description). Skills request secrets by name (e.g.
tavily_api_key). - The secret_versions row holds the encrypted blob. Each version is one encryption of one value. Storing versions instead of overwriting in place makes rotation additive — inserting a new version is the rotation, no schema changes required.
- Encryption is AES-256-GCM (symmetric, authenticated). Each version has a fresh 12-byte random nonce. The ciphertext includes the GCM auth tag, so any tampering is detected at decryption time.
The master key
The encryption key never lives in the database. It’s read from the MAESTRO_SECRET_KEY environment variable at process start.
MAESTRO_SECRET_KEY=base64-encoded-32-bytes
Generate one with pnpm db:gen-key — it prints a fresh key and exits without writing anywhere. Put it in your .env (which is gitignored).
The API process and the Python runtime both read the same env var and derive the same encryption key. Both processes can encrypt and decrypt; both refuse to start if the key is missing or malformed.
This split — values in DB, key in env — means a compromised database backup is not a compromised secrets vault. The attacker would need both the dump and the key.
Threat model (what this protects against)
| Scenario | Protected? |
|---|---|
| Operator with read-only DB access exfiltrates a backup. | Yes — ciphertext only. |
| Cloud provider snapshots leak. | Yes — ciphertext only. |
| Bug in skill code logs the secret value. | No — the value is in memory at the time of use. Skill authors must avoid logging secrets. |
| Compromised host process. | No — once the runtime decrypts a secret to use it, it’s plaintext in memory. |
MAESTRO_SECRET_KEY leaks. | No — the key plus a DB dump is full plaintext. |
| Skill author maliciously exfiltrates secrets. | No — skills are first-class code in your install. Trust the catalog you ship. |
Symmetric encryption with a master key is appropriate for a self-host product where the operator already has root on the box. It is not a substitute for hardware-backed key management; it is a substitute for “API keys live in .env next to the database password.”
How skills request secrets
Skills declare their required secrets in the manifest:
# skill.toml
name = "tavily-research"
version = "0.1.0"
[[secrets]]
name = "tavily_api_key"
kind = "api_key"
description = "API key from tavily.com"
At runtime, the SDK resolves them from the vault:
class TavilyResearch:
@operation(id="search", kind="tool")
async def search(self, input: SearchIn) -> list[Result]:
api_key = await self.secrets.require("tavily_api_key")
...
secrets.require() raises if the secret isn’t configured, which surfaces as a clear error in the run log: “web-research: secret tavily_api_key not configured.” The user sees what’s missing and where to fix it (the Skills → Web research page links straight to Secrets).
The chain is EnvSecretStore → DbSecretStore. Env vars win for local dev (a TAVILY_API_KEY in .env is picked up automatically). The DB vault is the production path. Skill code doesn’t know which store served the request — it just gets the value.
OAuth tokens
OAuth tokens are stored as a single secret with kind = "oauth2" and a JSON payload:
{
"access_token": "ya29.a0...",
"refresh_token": "1//0...",
"expires_at": 1777995923000,
"scopes": ["gmail.readonly", "gmail.send"]
}
The whole bundle is encrypted as one ciphertext. When a skill makes a Google API call and gets a 401, the SDK’s HTTP transport refreshes the access token, writes a new secret_versions row with the updated bundle, and retries. The skill code doesn’t see any of this — it just gets a successful response.
Refresh tokens never appear in logs. The skill detail UI shows “Connected as [email protected]” with no token material.
Adding and rotating secrets
From the Secrets page in the Maestro UI:
- Add — name, kind (
api_key/string/oauth2), description, value. The value field is the only place plaintext appears in the UI; once submitted, you can’t read it back. - Rotate — paste a new value. Inserts a new
secret_versionsrow; the previous version is kept for audit. The latest version always wins on read. - Delete — removes the secret and all its versions. Skills depending on it will start failing on next call.
Rotation is non-disruptive: in-flight skill calls continue using the version they read at start-of-call. New calls pick up the new version.
Operational notes
- Backups. Back up
MAESTRO_SECRET_KEYseparately from your database backups. Losing the key means every encrypted secret in every backup is unrecoverable. - Per-environment keys. Use a different
MAESTRO_SECRET_KEYfor dev, staging, and prod. A dev DB backup should not decrypt against the prod key. - Never log secret values. The SDK redacts known secret names from request/response logs. Skill authors should still avoid
console.log(secret_value)— there is no after-the-fact remediation. - Master-key rotation is not currently a one-click operation. It involves decrypting every version with the old key and re-encrypting with the new. Plan for v1.1.
Related
- Skills overview — how to declare and consume secrets in a skill.
- Gmail — concrete example of the OAuth flow end-to-end.
- The crypto primitives live in
apps/api/src/crypto.tsandskills/sdk/src/maestro_skills/crypto.py.