doppler run -- <cmd>. It does infra-config secrets well — versioning, environment
branching, syncs to 30+ platforms. But when the thing holding secrets is an agent acting on
behalf of many end-users, a Doppler config is a disconnected vendor store:
- Secrets live behind a service token (
dp.st.<config>...) scoped to one config — a single shared bucket, not per-end-user. To isolate secrets per customer you build the mapping yourself (one config per user, naming conventions, or your own KV on top). - Every secret in a config is readable by any code holding the token. There is no write-only secret — nothing an agent can use but never read back.
- “Which of this agent’s secrets belong to which end-user, and who let it read them?” is answered in Doppler for secrets, and in unrelated systems for the agent’s cards, email, and KYC. The identity that owns the secret is a config, not the end-user the agent serves.
vault primitive gives the agent the same capability — an
envelope-encrypted key/value store it reads at execution time — but every entry is rooted in one
governed identity:
- The tenant user whose vault holds a secret is the same user that owns its cards, its email inboxes, its phone number, and its KYC.
- Each value is envelope-encrypted with a managed KMS under an encryption context bound to
{ company_id, tenant_user_id }, so sibling tenants should not be able to read each other’s secrets — you never build the per-user isolation. - Entries can be
locked(stored but never revealed), expiring (expires_at), and whether the agent may touch the vault at all is decided by that user’s Account Kit at execution time.
Doppler is a trademark of its owner, used here for identification only. No endorsement or affiliation is implied.
Tested against: the Doppler Node SDK
@dopplerhq/node-sdk against the Doppler
API v3 (base https://api.doppler.com, /v3/configs/config/secret(s) endpoints, docs snapshot
June 2026), and the Naive Node SDK @usenaive-sdk/server against the Naive API (base
https://api.usenaive.ai/v1, docs snapshot June 2026).Version notes:- Scope model differs. Doppler scopes secrets by project + config and authenticates with a
token bound to a config. Naive scopes by tenant user —
naive.forUser(id)— and the same key reaches every one of that user’s primitives. - Storage shape is a near-rename. Doppler
secrets.update({ secrets: { KEY: value } })→ Naivevault.put(key, value). Dopplersecrets.get(...).value.computed→ Naivevault.reveal(key).value. The core store/read path is close to a one-to-one swap. rotatemeans different things. Doppler rotates the secret value (rotated/dynamic secrets). Naivevault.rotatere-wraps the encryption key — the stored value is unchanged. Do not treat these as equivalent (see what doesn’t map yet).- No equivalent yet for Doppler’s env injection (
doppler run), config inheritance/branching, integrations/syncs, dynamic secrets, or secret versioning/rollback — see what doesn’t map yet.
Concept map
| Doppler | Naive | Notes |
|---|---|---|
new DopplerSDK({ accessToken }) | new Naive({ apiKey }) then naive.forUser(id) | Server-side credential in both cases |
| project → config → secret hierarchy | tenant user → vault entry | Doppler scopes by environment/config; Naive scopes by the end-user identity |
Service token dp.st.<config> — read access to one shared config | tenant user via naive.forUser(id) — scopes every primitive | The core consolidation win — see gain #1 |
secrets.update({ project, config, secrets: { KEY: value } }) → POST /v3/configs/config/secrets | client.vault.put(key, value, { kind }) → PUT /v1/users/:id/vault/:key | Store or replace one secret (idempotent on the key) |
secrets.get(project, config, name) → .value.computed / .value.raw | client.vault.reveal(key) → .value | Read one secret back (Naive reveal is a POST — secret in body, never a URL) |
secrets.list(project, config) → { secrets: { KEY: {...} } } | client.vault.list() → { entries: [...] } (values masked) | List keys; Naive masks values and drops expired entries |
secrets.delete(project, config, name) → DELETE /v3/configs/config/secret | client.vault.delete(key) | Delete one entry |
secrets.download(project, config, { format: "env" }) → all secrets at once | — (loop list() then reveal() per key) | No bulk env-format dump — see gaps |
doppler run -- node app.js (inject secrets as env vars) | — (fetch with reveal at call time) | No runtime env injection |
Secret note (updateNote) / metadata | client.vault.put(key, value, { metadata }) | Arbitrary JSON attached to the entry |
| Rotated / dynamic secrets (value changes) | client.vault.rotate(key) re-wraps the KMS key (value unchanged); { regenerateDek: true } re-encrypts | Not the same operation — see gaps |
| — (no write-only secret) | client.vault.put(key, value, { locked: true }) | Stored, used indirectly, but never revealed back |
| — (no per-secret expiry) | client.vault.put(key, value, { expiresAt }) | Expired entries are omitted from list and typically return not found on reveal |
| Config access / secret change requests | Account Kit vault primitive enabled: true/false per user | Permission is execution-time policy on the identity — see gain #2 |
| Config inheritance / branching, environments | — | Naive vault is a flat per-user KV, not a config tree |
| Integrations / syncs (AWS SM, Vercel, 30+ platforms) | — | Naive vault is a store, not a sync hub |
| Dynamic secrets (issue short-lived DB/cloud creds) | — | Store static secrets only |
| Secret versioning + rollback, activity/audit log | Partial — Naive logs vault.* events; no version history/rollback | Every put/reveal/rotate lands in the per-user log |
Before / after: the core path
The path that matters for almost every agent is store a secret, then read it back when calling the downstream service. Here it is on both platforms.- The scope is an identity, not a config. In Doppler the token scopes a config; to serve many
users you build the per-user split. In Naive
forUser(id)is the split — one line, isolated end to end, and the same handle owns the user’s cards, email, phone, and KYC. revealis a POST. Naive returns the secret in the response body, never in a URL — so it can’t land in a proxy log or browser history.kindclassifies the entry.api_key,password,cookie,token,reference, ornote(default). It’s a label for the dashboard/logs, not a behavior change.- Values come back masked in
list.vault.list()returns keys with••••••••placeholders and omits expired entries; onlyrevealdecrypts.
Minimal viable migration
The smallest swap that keeps a working agent running is store + read + delete.Install the SDK and set your key
NAIVE_API_KEY (a server-side key from the dashboard).Pick the identity the secret belongs to
Replace
new DopplerSDK({ accessToken }) + your project/config selection with
naive.forUser(id), where id is the end-user (or your own default user) the secret is for.
There is no config to choose — the user is the scope.Swap writes
Map
secrets.update({ secrets: { KEY: value } }) → vault.put(key, value, { kind }). put is
idempotent on the key, so a re-store overwrites in place. Prefer lowercase, dotted keys
(instantly.api_key) — Naive keys are renameable and not case-transformed.Swap reads
Map
secrets.get(project, config, name).value.computed → vault.reveal(key) (returns
{ key, value, expires_at }), and secrets.list(...) → vault.list() (values masked). Reveal
over POST, not GET.Consolidate further once you’re on Naive
This is where the migration pays for itself. In Doppler, a service token isolates a config and nothing else, and every secret in it is readable by whatever holds the token. On Naive, the unit of isolation is a tenant user, and the vault is one primitive on an identity that also owns the agent’s cards, email, and phone.Gain #1 — one identity across primitives
- With Doppler, the agent’s secrets are an island behind a config token. With Naive,
naive.forUser(acme.id)is a single handle to vault and cards and email and phone and KYC. - Each value is envelope-encrypted with a managed KMS under an encryption context bound to
{ company_id, tenant_user_id }— sibling tenants cannot read each other’s secrets, cards, or inboxes, and you never wrote the isolation. - Third-party connections (OAuth grants) surface alongside vault entries in one per-user credential view — the SaaS keys the agent generated and the OAuth apps it connected, one identity.
Gain #2 — execution-time permission enforcement
- Whether an agent may touch the vault at all is the
vaultprimitive on the user’s Account Kit, decided at execution time — not a token scope you manage separately.
- The agent’s code is identical for every tier —
client.vault.reveal("instantly.api_key"). Whether it runs is decided by policy: a user whose Account Kit does not enablevaultis refused withforbidden, with no code change on your side. - Two enforcement levers Doppler has no equivalent for:
lockedentries — stored and used indirectly, butrevealis typically refused (forbiddenper the vault API). A leaked agent process still should not be able to read the raw value back out.expires_at— an entry drops out oflistandrevealtypically returns not found once expired, reducing replay of stale session cookies or short-lived tokens.
Unlike money-moving primitives (cards, trading, domains), vault reads are not frozen for human
approval by default — the enforcement model is enable/disable the primitive, lock an entry, or
expire it, not an approval queue. If you need a human in the loop before a secret is used, gate the
downstream primitive that consumes it instead.
Gain #3 — unified accountability
- Every
put,reveal, androtatefor a customer lands in one per-user activity log — alongside their card, email, phone, and KYC events, not in a separate Doppler activity feed:
- That is the question that is hard to answer when secrets live in Doppler, cards live in Stripe, and the inbox lives somewhere else. Under Naive it is a single query.
What does not map yet
A migration guide that hides gaps is worse than none. The core path (store → reveal → list → delete, pluslocked/expires_at/rotate) maps cleanly, but the following Doppler capabilities have no
direct equivalent on Naive’s vault primitive today. Check this list against your app before you
commit.
| Doppler feature | Status on Naive | Workaround |
|---|---|---|
Env injection (doppler run -- <cmd>, .env mounts) | Not provided | Fetch with vault.reveal(key) at call time and set process state yourself |
Bulk download (secrets.download, env/yaml/docker formats) | Not provided | Loop vault.list() then vault.reveal() per key |
| Config inheritance / branching, environments (dev/stg/prd) | Not provided | Encode environment in the key or use separate tenant users |
Secret referencing (${OTHER_SECRET}, .value.computed) | Not provided — reveal returns the raw stored value | Compose values in your own code before put |
| Integrations / syncs (AWS Secrets Manager, Vercel, GitHub, 30+) | Not provided | Naive vault is a store, not a sync hub — keep Doppler for platform config if you sync |
| Dynamic secrets (issue short-lived DB/cloud creds on demand) | Not provided | Store static secrets; rotate re-wraps the KMS key, it does not mint new credentials |
| Rotated secrets (Doppler changes the value on a schedule) | Different operation — vault.rotate re-wraps encryption, value unchanged | Re-put the new value when your source rotates it |
| Secret versioning + rollback | Not provided | Keep a version trail in your own store if you need rollback |
| Personal configs, webhooks, trusted IPs, project RBAC | Not provided (Account Kit gates the primitive, not per-secret roles) | Use Account Kits + connections config for coarse policy |
Where to go next
vaultprimitive — full put/reveal/list/delete/rotate lifecycle,kind,locked,expires_atvaultSDK sub-client — typed method signaturesvaultCLI —naive vault put/reveal/listper user- Vault encryption — envelope encryption, KMS, and the
{ company_id, tenant_user_id }context - Account Kits — enabling/disabling the
vaultprimitive per user at execution time - Tenant users — the identity that owns the vault, cards, email, and phone
- Connections — OAuth grants that surface alongside vault entries in one per-user credential view