How it works
OpaqueVault is built around one principle: the server should never be able to decrypt your secrets, and the AI should never see them either. Everything follows from that.
The two-process model
Section titled “The two-process model”Claude Code ──MCP/stdio──▶ ov mcp serve ──HTTPS+PQC──▶ api.opaquevault.comThree actors. Each has a strictly limited role:
Claude Code (or any MCP client)
Section titled “Claude Code (or any MCP client)”- Calls MCP tools by name:
vault_run,vault_list_secrets,vault_status - Never requests secret values — only the ability to act with them
- Receives exit codes and injection metadata — subprocess stdout/stderr are not returned at all, and never plaintext values
- If a raw secret accidentally enters the MCP channel, the interceptor blocks it
ov mcp serve — the local bridge
Section titled “ov mcp serve — the local bridge”- Runs on your machine as a stdio MCP server
- Holds no key material itself — it connects to the local agent daemon, which holds a scope-derived keyring in memory (the raw KEK is zeroized after unlock)
- Fetches encrypted blobs from the API, decrypts them locally
- Injects plaintext values as env vars into subprocesses — values exist in memory for milliseconds, then are zeroed
- Runs the MCP context interceptor: scans all inbound MCP messages for secret patterns before they reach Claude
api.opaquevault.com — the blob store
Section titled “api.opaquevault.com — the blob store”- Stores only ciphertext — it has no decryption capability
- No decrypt endpoint. No key escrow. No master password.
- A fully compromised server exposes nothing useful — all blobs require your KEK to decrypt
What happens when Claude calls vault_run
Section titled “What happens when Claude calls vault_run”- Claude calls
vault_run({ command: ["go", "run", "./cmd/migrate"], secret_names: ["DATABASE_URL"], expected_app: "myapp", expected_env: "dev" }) ov mcp servereceives the call- The agent daemon (which holds a scope-derived keyring — the bridge never holds key material) fetches the encrypted blob for
DATABASE_URLfrom the API over ML-KEM-768 + X25519 hybrid TLS - The daemon unwraps the DEK using its keyring, then decrypts the ciphertext using the DEK
- Subprocess spawns with
DATABASE_URL=postgres://...in its environment - DEK and plaintext are zeroed from memory
- MCP response returns:
{ exit_code: 0, redacted: true, invocation_id: "…" }— the subprocess output goes to the daemon terminal / agent log, never the response (v0.16.0) - Claude receives the response — no secret value, and no subprocess output, anywhere in it
The interceptor
Section titled “The interceptor”The interceptor is a middleware layer inside ov mcp serve that scans the results OpaqueVault’s MCP tools return, before they reach Claude.
What it detects: Known secret formats (AWS access keys, GitHub tokens, Stripe keys, private keys, JWTs, connection strings) plus high-entropy strings that match no known format.
What it does when it fires:
- BLOCK mode (default): drops the entire tool result and returns an error telling Claude a potential secret was intercepted and pointing it at
vault_create_secret/vault_runinstead - REDACT mode (
OV_INTERCEPT_MODE=redact): replaces each detected value with[REDACTED:detector-name]and lets the surrounding context through
Any other value of OV_INTERCEPT_MODE falls back to BLOCK. There is no mode that disables it.
Why this matters: it is a backstop, not the primary defense. Step 7 above is the primary one — subprocess output is never read into the response in the first place. The interceptor catches a credential that reaches a payload some other way.
What it does not cover: the interceptor only sees tool results flowing out of ov mcp serve. It never sees your prompts — anything you type or paste into Claude Code goes straight to the model without passing through ov. And a subprocess you have granted secrets to can defeat pattern matching by encoding what it sends. See the interceptor reference for the full scope.
The encryption stack
Section titled “The encryption stack”master password ↓ Argon2id (time=1, mem=64MB, threads=4)KEK — 32 bytes, lives only in client memory, never transmitted ↓ AES-256-GCM + random nonceDEK — 32 random bytes per secret, zeroed after use ↓ AES-256-GCM + random nonceciphertext — all the server ever storesTransport uses ML-KEM-768 + X25519 hybrid KEM (NIST FIPS 203). If either primitive is broken, the other protects you. There is no X25519-only fallback. No downgrades.
ov scan — finding secrets before they leak
Section titled “ov scan — finding secrets before they leak”ov scan is a local repo scanner. Detection is entirely local and needs no account — a plain ov scan . sends no file contents, line context, or findings anywhere.
Two flags do reach the network, both opt-in: --fix stores the credentials you confirm in your OpaqueVault vault, and --verify checks a detected credential’s liveness against its issuing vendor’s API (which means the credential is sent to that vendor and the check shows up in their activity logs). Separately, the ov binary itself performs a once-daily version check against releases.opaquevault.com, which sends no repository data and is skipped in CI, on non-TTY output, and when OV_NO_UPDATE_CHECK is set. (The separate ov scan-diff-base CI helper may also fetch from your repo’s own origin remote to deepen a shallow clone for diff-aware scanning — standard git history retrieval, not part of ov scan itself, and it never transmits file contents or findings.)
ov scan .It walks your git-tracked files and detects: AWS access keys, GitHub tokens, Stripe keys, private keys, JWT tokens, Postgres/MySQL/MongoDB connection strings, and high-entropy blobs.
ov scan . --fix# prompts to store found secrets in OpaqueVault,# then prints git filter-repo cleanup instructionsov scan uses the same detection engine (internal/detect/) as the MCP interceptor — the same patterns that guard MCP tool results also catch committed secrets in your repo.
Security invariants
Section titled “Security invariants”These rules are enforced in code and verified by tests:
- KEK never leaves the client
- Plaintext secret values never appear in MCP responses — no tool is built to return one, and
vault_run’s response carries no subprocess output at all; a pattern-based interceptor backstops the content that is returned - Nonces are never reused —
crypto/rand.Readfor every operation - Master password is never persisted — sessions hold only derived, scoped key material, and the raw KEK is zeroized after each unlock
- DEKs are zeroed after use
- No
get_secretMCP tool — ever - The API has no decrypt endpoint
- Argon2id parameters are hardcoded client-side — not negotiated with the server
- ML-KEM-768 is always part of the hybrid — no X25519-only fallback
- Audit log secret references are HMAC hashed — not plaintext IDs or names
- The interceptor runs before any message reaches Claude — middleware wraps every outbound MCP tool result
ov scannever transmits file contents, line context, or findings to any server — detection is entirely local. Only the opt-in--fix(storing values you confirm in your vault) and--verify(checking a credential against its issuing vendor) make network calls, and each sends only the credential value itself. Theovbinary’s once-daily version check sends no repository data (the separateov scan-diff-baseCI helper may additionally fetch from the repo’s own origin remote to resolve a merge base — no file contents or findings, just standard git history retrieval)- Audit log plaintext resource references never appear on the API wire —
resource_id_hmaconly; never the bareresource_idkey