Skip to main content
Doppler
Doppler is a secrets manager: you organize secrets into a project → config → secret hierarchy, then read them back over the API/SDK or inject them into a process with 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.
Naive’s 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.
This guide maps Doppler’s secrets API to Naive’s, shows the smallest working swap, and is explicit about what does not map yet.
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 usernaive.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 } }) → Naive vault.put(key, value). Doppler secrets.get(...).value.computed → Naive vault.reveal(key).value. The core store/read path is close to a one-to-one swap.
  • rotate means different things. Doppler rotates the secret value (rotated/dynamic secrets). Naive vault.rotate re-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

DopplerNaiveNotes
new DopplerSDK({ accessToken })new Naive({ apiKey }) then naive.forUser(id)Server-side credential in both cases
project → config → secret hierarchytenant user → vault entryDoppler scopes by environment/config; Naive scopes by the end-user identity
Service token dp.st.<config> — read access to one shared configtenant user via naive.forUser(id) — scopes every primitiveThe core consolidation win — see gain #1
secrets.update({ project, config, secrets: { KEY: value } })POST /v3/configs/config/secretsclient.vault.put(key, value, { kind })PUT /v1/users/:id/vault/:keyStore or replace one secret (idempotent on the key)
secrets.get(project, config, name).value.computed / .value.rawclient.vault.reveal(key).valueRead 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/secretclient.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) / metadataclient.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-encryptsNot 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 requestsAccount Kit vault primitive enabled: true/false per userPermission is execution-time policy on the identity — see gain #2
Config inheritance / branching, environmentsNaive 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 logPartial — Naive logs vault.* events; no version history/rollbackEvery 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.
import { DopplerSDK } from "@dopplerhq/node-sdk";

// One config behind a service token — a shared bucket, not per end-user.
const doppler = new DopplerSDK({ accessToken: process.env.DOPPLER_TOKEN! });

const PROJECT = "agent";
const CONFIG = "prd";

// 1. Store a secret the agent generated (POST /v3/configs/config/secrets)
await doppler.secrets.update({
  project: PROJECT,
  config: CONFIG,
  secrets: { INSTANTLY_API_KEY: "key_xyz" },
});

// 2. Read it back when calling the downstream service
//    (GET /v3/configs/config/secret) → { name, value: { raw, computed, note } }
const res = await doppler.secrets.get(PROJECT, CONFIG, "INSTANTLY_API_KEY");
const apiKey = res.value.computed;

// To isolate per end-user you build it yourself: a config per user,
// naming conventions, or your own KV on top of Doppler.
import { Naive } from "@usenaive-sdk/server";

const naive = new Naive({ apiKey: process.env.NAIVE_API_KEY! });
const client = naive.forUser("alice"); // same id space as your own users

// 1. Store a secret the agent generated (PUT /v1/users/alice/vault/:key)
await client.vault.put("instantly.api_key", "key_xyz", { kind: "api_key" });

// 2. Read it back when calling the downstream service
//    (POST /v1/users/alice/vault/:key/reveal — secret in body, never a URL)
const { value } = await client.vault.reveal("instantly.api_key");
const apiKey = value;

// Per-user isolation is built in: this entry is envelope-encrypted under
// { company_id, tenant_user_id } — sibling tenants should not be able to read it.
The store/read shape lines up almost exactly. The real differences to plan for:
  • 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.
  • reveal is 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.
  • kind classifies the entry. api_key, password, cookie, token, reference, or note (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; only reveal decrypts.

Minimal viable migration

The smallest swap that keeps a working agent running is store + read + delete.
1

Install the SDK and set your key

npm install @usenaive-sdk/server
Set NAIVE_API_KEY (a server-side key from the dashboard).
2

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

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

Swap reads

Map secrets.get(project, config, name).value.computedvault.reveal(key) (returns { key, value, expires_at }), and secrets.list(...)vault.list() (values masked). Reveal over POST, not GET.
5

Swap deletes

Map secrets.delete(project, config, name)vault.delete(key).
6

Ship it

At this point the agent’s store/read path is off Doppler. Everything below is upside, not a requirement.

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.
// One config per token; to go multi-user you build the split yourself.
const doppler = new DopplerSDK({ accessToken: process.env.DOPPLER_TOKEN! });
await doppler.secrets.update({
  project: "agent", config: "prd",
  secrets: { INSTANTLY_API_KEY: "key_xyz" },
});

// The card, the email inbox, the phone number, the KYC for this customer's
// agent live in entirely separate systems with their own tenancy — and every
// secret in the config is readable by anything holding the token.
// One tenant user per customer; isolates *every* primitive.
const acme = await naive.users.create({
  external_id: dbCustomer.id,
  email: dbCustomer.email,
});

const client = naive.forUser(acme.id);

// A write-only credential the agent can USE but never READ back.
await client.vault.put("stripe.restricted_key", "rk_live_xyz", {
  kind: "api_key",
  locked: true,
});

// A short-lived session cookie that self-expires.
await client.vault.put("portal.session", "sess_abc", {
  kind: "cookie",
  expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
});

// The SAME client owns this customer's card, email inbox, phone, and KYC —
// one identity, isolated end to end, every capability governed by its Account Kit.
await client.cards.create({ spending_limit_cents: 25_000 });
await client.email.createInbox({ local_part: "ops" });

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 vault primitive on the user’s Account Kit, decided at execution time — not a token scope you manage separately.
// This tier's agents get email + cards, but no vault access at all.
const kit = await naive.accountKits.create({
  name: "No-secrets tier",
  primitives_config: {
    email: { enabled: true },
    cards: { enabled: true },
    vault: { enabled: false }, // vault calls are refused with `forbidden`
  },
});
await naive.accountKits.assignUser(kit.id, acme.id);
  • 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 enable vault is refused with forbidden, with no code change on your side.
  • Two enforcement levers Doppler has no equivalent for:
    • locked entries — stored and used indirectly, but reveal is typically refused (forbidden per 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 of list and reveal typically 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, and rotate for a customer lands in one per-user activity log — alongside their card, email, phone, and KYC events, not in a separate Doppler activity feed:
const { events } = await naive.forUser(acme.id).logs.query({ limit: 50 });
// "what did this agent do, for whom?" — vault reveals, card spend, email sends, one timeline
  • 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, plus locked/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 featureStatus on NaiveWorkaround
Env injection (doppler run -- <cmd>, .env mounts)Not providedFetch with vault.reveal(key) at call time and set process state yourself
Bulk download (secrets.download, env/yaml/docker formats)Not providedLoop vault.list() then vault.reveal() per key
Config inheritance / branching, environments (dev/stg/prd)Not providedEncode environment in the key or use separate tenant users
Secret referencing (${OTHER_SECRET}, .value.computed)Not provided — reveal returns the raw stored valueCompose values in your own code before put
Integrations / syncs (AWS Secrets Manager, Vercel, GitHub, 30+)Not providedNaive 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 providedStore 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 unchangedRe-put the new value when your source rotates it
Secret versioning + rollbackNot providedKeep a version trail in your own store if you need rollback
Personal configs, webhooks, trusted IPs, project RBACNot provided (Account Kit gates the primitive, not per-secret roles)Use Account Kits + connections config for coarse policy
The biggest behavior change is model, not method: Doppler is built to hold your infrastructure config and inject it into a process (doppler run), with environment branching, syncs, and versioning. Naive vault holds the per-end-user secrets an agent encounters at runtime — a SaaS key it generated, a session cookie, a KYC reference — scoped to one governed identity, read with an explicit reveal. If you rely on env injection, integrations/syncs, dynamic secrets, or versioning/rollback, those are the gaps most likely to matter, and it is reasonable to keep Doppler for your own service’s build/deploy config while moving agent-held, per-user secrets to the vault. Also note rotate is not rotation of the value — it re-wraps the KMS key; the stored secret is unchanged.

Where to go next

  • vault primitive — full put/reveal/list/delete/rotate lifecycle, kind, locked, expires_at
  • vault SDK sub-client — typed method signatures
  • vault CLInaive vault put/reveal/list per user
  • Vault encryption — envelope encryption, KMS, and the { company_id, tenant_user_id } context
  • Account Kits — enabling/disabling the vault primitive 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