A self-hosted secrets vault for AI-agent workflows. Secrets live on the server — never in
.envfiles, never in LLM context. Any project authenticates with a single Ed25519 keypair. One env var. Zero leaks.
Developer manages secrets via dashboard or CLI
↓
Vault stores them encrypted at rest (AES-256-GCM)
↓
Your app authenticates with ONE env var: OPAQUE_PRIVATE_KEY
↓
At boot, app signs a request → vault returns secrets → injected into process.env
↓
LLM / agent context sees variable names only. Values never transit to the model.
# Install the CLI
npm install -g @florianjs/opaque-cli
# Install the SDK in your app
npm install @florianjs/opaque # core (any runtime)
npm install @florianjs/opaque-node # Node.js
npm install @florianjs/opaque-next # Next.js
npm install @florianjs/opaque-nuxt # Nuxt- Prerequisites
- Setting up the vault
- Dashboard
- Registering a project
- Managing secrets — CLI
- Integrating in your app — SDK
- Key rotation
- Production deployment
- Security model
- API reference
- Development commands
- Repository structure
- LLM agent instructions
| Tool | Version | Install |
|---|---|---|
| Bun | ≥ 1.1 | curl -fsSL https://bun.sh/install | bash |
Vite+ (vp) |
latest | curl -fsSL https://vite.plus | bash |
bun --version # 1.1+
vp --versiongit clone https://github.com/florianjs/opaque.git
cd opaque
vp installCreate apps/server/.env — never commit this file:
OPAQUE_MASTER_KEY="<32-byte hex>" # openssl rand -hex 32
OPAQUE_ADMIN_TOKEN="<random secret>" # openssl rand -hex 32
OPAQUE_PORT=4200
DATABASE_URL="file:./opaque.db"| Variable | Required | Description |
|---|---|---|
OPAQUE_MASTER_KEY |
yes | 32-byte hex key for AES-256-GCM encryption of secrets at rest |
OPAQUE_ADMIN_TOKEN |
yes | Bearer token for dashboard and CLI management operations |
OPAQUE_PORT |
no | Port to listen on (default: 4200) |
DATABASE_URL |
no | LibSQL/SQLite connection string (default: file:./opaque.db) |
cd apps/server
DATABASE_URL="file:./opaque.db" pnpm exec drizzle-kit migrate
cd ../..The dashboard is a static Vue 3 SPA served by the vault at /ui. Build it once before starting:
pnpm --filter @florianjs/ui buildvp run server:devThe vault starts at http://localhost:4200 with hot reload. Visit http://localhost:4200/ui for the dashboard.
curl http://localhost:4200/health
# {"ok":true}The dashboard is served at http://localhost:4200/ui by the vault itself.
On first visit, enter your OPAQUE_ADMIN_TOKEN — it is stored in localStorage and used for all admin API calls.
Capabilities:
- Browse and manage projects
- Add / delete secrets per project and environment (write-only — values are never displayed)
- View audit log
pnpm --filter @florianjs/ui build
# then restart the vaultvp dev # Vite HMR, proxies /v1 to localhost:4200Each application that needs secrets must be registered on the vault. Registration generates an Ed25519 keypair: the vault stores the public key, you store the private key in your CI/CD.
# Install the CLI
npm install -g @florianjs/opaque-cli
# Point it at your vault
export OPAQUE_VAULT_URL="http://localhost:4200"
export OPAQUE_ADMIN_TOKEN="<your admin token>"
# Register
opaque register --project my-app
# → OPAQUE_PRIVATE_KEY={"kty":"OKP","crv":"Ed25519","d":"...","x":"..."}
#
# Add this value to your CI/CD secrets — it is the only secret your app needs.Go to http://localhost:4200/ui → Projects → New project. The private key is shown once — copy it to your CI/CD immediately.
export OPAQUE_VAULT_URL="http://localhost:4200"
export OPAQUE_ADMIN_TOKEN="<your admin token>"opaque set --project my-app --env production DATABASE_URL=postgres://...
opaque set --project my-app --env production STRIPE_SECRET_KEY=sk_live_...
opaque set --project my-app --env development DATABASE_URL=postgres://localhost/myappThe --env value is a free string — use production, development, branch names, PR numbers, anything.
opaque list --project my-app --env production
# DATABASE_URL production 2026-03-19
# STRIPE_SECRET_KEY production 2026-03-19opaque get --project my-app --env production DATABASE_URLopaque pull --project my-app --env production
# DATABASE_URL=postgres://...
# STRIPE_SECRET_KEY=sk_live_...opaque audit --project my-app
# 2026-03-19 12:00:01 fetch production 192.168.1.1Every application needs exactly three environment variables:
OPAQUE_PRIVATE_KEY="<printed by opaque register>"
OPAQUE_VAULT_URL="https://your-vault.example.com"
OPAQUE_PROJECT="my-app"At boot, the SDK signs a request with the private key, fetches the secrets from the vault, and injects them into process.env. Your application code reads from process.env as usual — nothing changes except secrets no longer live in your repo or deployment config.
npm install @florianjs/opaque-node// server.ts — must be the very first import
import { bootstrap } from "@florianjs/opaque-node";
await bootstrap();
// Secrets are now in process.env
import { startServer } from "./app";
startServer();npm install @florianjs/opaque-nextCreate instrumentation.ts at the project root:
import { register } from "@florianjs/opaque-next";
export { register };Enable in next.config.ts:
const nextConfig = {
experimental: { instrumentationHook: true },
};
export default nextConfig;npm install @florianjs/opaque-nuxt// nuxt.config.ts
export default defineNuxtConfig({
modules: ["@florianjs/opaque-nuxt"],
opaque: {
vaultUrl: process.env.OPAQUE_VAULT_URL,
privateKey: process.env.OPAQUE_PRIVATE_KEY,
project: process.env.OPAQUE_PROJECT,
},
});npm install @florianjs/opaqueimport { fetchSecrets, injectEnv } from "@florianjs/opaque";
const secrets = await fetchSecrets({
vaultUrl: process.env.OPAQUE_VAULT_URL!,
privateKey: process.env.OPAQUE_PRIVATE_KEY!,
project: process.env.OPAQUE_PROJECT!,
env: "production", // optional, defaults to process.env.NODE_ENV
});
injectEnv(secrets, process.env as Record<string, string>);Rotate a project keypair without downtime:
# 1. Generate new keypair — old key stays valid for 10 minutes
opaque rotate --project my-app
# → New OPAQUE_PRIVATE_KEY={"kty":"OKP",...}
# 2. Update OPAQUE_PRIVATE_KEY in your CI/CD secrets
# 3. Redeploy your application
# After 10 minutes the vault drops the old key automatically.OPAQUE_MASTER_KEY="<32-byte hex — store in your secrets manager, never in code>"
OPAQUE_ADMIN_TOKEN="<long random token — store in your secrets manager>"
OPAQUE_PORT=4200
DATABASE_URL="libsql://your-db.turso.io?authToken=<token>" # Turso for edgecd apps/server
DATABASE_URL="$DATABASE_URL" pnpm exec drizzle-kit migratepnpm --filter @florianjs/ui build # build dashboard → apps/ui/dist/
vp run server:build # compile vault → apps/server/dist/
vp run server:start # run compiled vaultThe vault must be behind HTTPS in production. Example Caddyfile:
vault.example.com {
reverse_proxy localhost:4200
}
FROM oven/bun:1 AS builder
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile
RUN bun build apps/server/src/index.ts --outdir apps/server/dist --target bun
RUN bun x vite build --config apps/ui/vite.config.ts
FROM oven/bun:1-slim
WORKDIR /app
COPY --from=builder /app/apps/server/dist ./dist
COPY --from=builder /app/apps/ui/dist ./apps/ui/dist
COPY --from=builder /app/node_modules ./node_modules
ENV OPAQUE_PORT=4200
EXPOSE 4200
CMD ["bun", "run", "dist/index.js"]fly launch --name opaque-vault
fly secrets set OPAQUE_MASTER_KEY="..." OPAQUE_ADMIN_TOKEN="..." DATABASE_URL="..."
fly deploy| Threat | Mitigation |
|---|---|
| Secret value leaked to LLM | Injected server-side only. Dashboard is write-only — values never displayed. |
| Private key stolen | opaque rotate — old key invalid after 10-minute overlap window. |
| MITM | TLS required in production. Vault must be behind HTTPS. |
| Replay attack | Nonce + expires on every request. Nonces cached for 10 minutes. |
| Secrets at rest | AES-256-GCM. Master key in env var, never in DB. |
| Unauthorized access | Ed25519 signature verification on all /v1/secrets/*. Separate OPAQUE_ADMIN_TOKEN for admin ops. |
| Brute force | Rate limiting: 100 req/min per IP. |
1. App reads OPAQUE_PRIVATE_KEY from process.env
2. signRequest()
├─ Parse Ed25519 private key JWK
├─ Build RFC 9421 canonical message (@method, @authority, @target-uri)
├─ Add created / expires (5 min window) / nonce to Signature-Input
└─ Sign with Ed25519
3. GET /v1/secrets?env=production
Signature: sig1=:<base64>:
Signature-Input: sig1=("@method" "@authority" "@target-uri");created=...;nonce=...
Signature-Agent: sig1=my-app.agents.opaque.local;pubkey="..."
4. Vault authMiddleware
├─ Extract projectId from Signature-Agent
├─ Fetch project.publicKey from DB
├─ Verify Ed25519 signature
├─ Check created/expires window
├─ Check nonce not replayed
└─ Pass → set ctx.projectId
5. Vault decrypts AES-256-GCM → returns { KEY: "value", ... }
6. injectEnv() merges into process.env
| Method | Path | Description |
|---|---|---|
GET |
/v1/admin/projects |
List all projects |
POST |
/v1/admin/projects |
Create a project { name, publicKey } |
DELETE |
/v1/admin/projects/:id |
Delete a project (cascades secrets) |
PUT |
/v1/admin/projects/:id/rotate |
Rotate project keypair |
GET |
/v1/admin/projects/:id/secrets |
List secret keys (no values) |
DELETE |
/v1/admin/secrets/:id |
Delete a secret |
GET |
/v1/admin/audit |
Audit log ?projectId=&limit= |
| Method | Path | Description |
|---|---|---|
GET |
/v1/secrets?env=production |
Fetch and decrypt all secrets |
PUT |
/v1/secrets |
Create/update a secret { key, value, env } |
DELETE |
/v1/secrets/:id |
Delete a secret |
| Method | Path | Description |
|---|---|---|
GET |
/health |
Returns { ok: true } |
vp install # install all workspace dependencies
vp run server:dev # start vault server (Bun hot reload, port 4200)
vp dev # start dashboard dev server (Vite HMR)
vp check # format + lint + typecheck (Oxfmt + Oxlint)
vp check --fix # auto-fix formatting and lint issues
vp test # run all tests (Vitest)
vp run pack # build SDK packages for npm (Rolldown + tsdown)
vp build # production build (all packages + apps)
vp run db:migrate # run Drizzle migrations
vp run db:studio # open Drizzle Studio (DB browser)Single test file:
vp test packages/core/src/crypto.test.tsopaque/
├── packages/
│ ├── core/ @florianjs/opaque — zero-dep TypeScript SDK (fetch, inject, watch, rotate, crypto)
│ ├── nuxt/ @florianjs/opaque-nuxt — Nuxt module (hooks into nitro:init)
│ ├── next/ @florianjs/opaque-next — Next.js instrumentation.ts adapter
│ ├── node/ @florianjs/opaque-node — bare Node bootstrap()
│ └── cli/ @florianjs/opaque-cli — management CLI (citty)
└── apps/
├── server/ Hono + Bun vault — routes, middleware, db, crypto (port 4200)
└── ui/ Vue 3 dashboard — served as static at /ui
📋 Add this section to your system prompt or AGENTS.md
This project uses opaque to manage secrets. Secret values are never stored in
.envfiles, never committed to the repository, and never passively present in your context — they are fetched at runtime by the application itself via a signed Ed25519 request.What opaque protects against: accidental leakage through code, git history, logs, or passive context. Secret values will not appear in your context unless you deliberately retrieve them with a tool call.
What opaque does not prevent: an agent with shell access and
OPAQUE_PRIVATE_KEYin its environment can still retrieve secret values viaopaque pullor by signing a direct API call. All access is traced — every fetch is logged with a timestamp and IP in the audit table.As an AI agent, follow these rules:
- To check which secrets exist:
opaque list --project <name> --env <env>— returns key names only, never values.- To add or update a secret:
opaque set --project <name> --env <env> KEY=value.- To retrieve secrets into a shell for local scripting:
opaque pull --project <name> --env <env>— only do this when explicitly instructed by the user.- To rotate a compromised key:
opaque rotate --project <name>, then updateOPAQUE_PRIVATE_KEYin the deployment environment.- Never hardcode secret values in source files, config files, or commit messages.
- Never print, log, or include secret values in your responses or tool call outputs.
- Never read or expose
OPAQUE_MASTER_KEYorOPAQUE_ADMIN_TOKEN.
MIT