Skip to content

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.


Claude Code ──MCP/stdio──▶ ov mcp serve ──HTTPS+PQC──▶ api.opaquevault.com

Three actors. Each has a strictly limited role:

  • 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
  • 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
  • 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

  1. Claude calls vault_run({ command: ["go", "run", "./cmd/migrate"], secret_names: ["DATABASE_URL"], expected_app: "myapp", expected_env: "dev" })
  2. ov mcp serve receives the call
  3. The agent daemon (which holds a scope-derived keyring — the bridge never holds key material) fetches the encrypted blob for DATABASE_URL from the API over ML-KEM-768 + X25519 hybrid TLS
  4. The daemon unwraps the DEK using its keyring, then decrypts the ciphertext using the DEK
  5. Subprocess spawns with DATABASE_URL=postgres://... in its environment
  6. DEK and plaintext are zeroed from memory
  7. 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)
  8. Claude receives the response — no secret value, and no subprocess output, anywhere in it

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_run instead
  • 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.


master password
↓ Argon2id (time=1, mem=64MB, threads=4)
KEK — 32 bytes, lives only in client memory, never transmitted
↓ AES-256-GCM + random nonce
DEK — 32 random bytes per secret, zeroed after use
↓ AES-256-GCM + random nonce
ciphertext — all the server ever stores

Transport 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.)

Terminal window
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.

Terminal window
ov scan . --fix
# prompts to store found secrets in OpaqueVault,
# then prints git filter-repo cleanup instructions

ov 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.


These rules are enforced in code and verified by tests:

  1. KEK never leaves the client
  2. 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
  3. Nonces are never reused — crypto/rand.Read for every operation
  4. Master password is never persisted — sessions hold only derived, scoped key material, and the raw KEK is zeroized after each unlock
  5. DEKs are zeroed after use
  6. No get_secret MCP tool — ever
  7. The API has no decrypt endpoint
  8. Argon2id parameters are hardcoded client-side — not negotiated with the server
  9. ML-KEM-768 is always part of the hybrid — no X25519-only fallback
  10. Audit log secret references are HMAC hashed — not plaintext IDs or names
  11. The interceptor runs before any message reaches Claude — middleware wraps every outbound MCP tool result
  12. ov scan never 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. The ov binary’s once-daily version check sends no repository data (the separate ov scan-diff-base CI 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)
  13. Audit log plaintext resource references never appear on the API wire — resource_id_hmac only; never the bare resource_id key