MainSDK
Search⌘K
Interfaces

Security and privacy

Secrets

secrets

Refer to provider keys from main.yaml as secret:NAME, keep them out of code, env files and logs, and have mainsdk check fail a leaked key.

Building for 0.2ships as a stable APIPhase 1 · Core, public betaSpec config-secretsFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

secrets keeps provider keys and tokens out of your code: in main.yaml you write secret:NAME, never the value, and a key in code or a committed env file fails mainsdk check.

At a glance

secrets:
  driver: vault
  vault: { address: https://vault.pinecart.internal, auth: workload-identity }
email:
  key: secret:RESEND_API_KEY

Calls

  • secrets.get()
  • secrets.raw()
  • resolveSecret(ref, source?)
  • secrets.all(name)
  • secrets.age(name)
  • secrets.watch(name, fn)

Guarantees

Whatever the driver.

  • Importable only in server code
  • Values redacted from logs, errors and telemetry
  • Last good value served when the provider is unreachable, never written to disk
  • A key in code, a committed env file or the browser bundle fails the check

Drivers

8 drivers. Select one to see its line in main.yaml.

main.yamlHashiCorp Vault (default)
secrets:
  driver: vault

Change this one line to swap providers; your calls stay the same.HashiCorp Vault integration

Codes

CodeWhere it appearsMeaning
MAIN-SECRET-0001Checkmainsdk check, before deploymainsdk/secrets imported from client code
MAIN-SECRET-0002Checkmainsdk check, before deployA key pattern in source code
MAIN-SECRET-0003Checkmainsdk check, before deployA committed .env file other than .env.example
MAIN-SECRET-0004Checkmainsdk check, before deployA value from a secret: reference is in a browser bundle
MAIN-SECRET-0011Checkmainsdk check, before deployA secret is past its max-age (fails mainsdk check --env <env> when the driver can report ages; a Main 27 exception covers it)
MAIN-SECRET-0014Checkmainsdk check, before deployA secrets.rotation value is invalid: a bad duration, overlap longer than max-age, generate under 16 bytes, or a name the app doesn't reference
MAIN-CONFIG-0004Checkmainsdk check, before deployA literal value in a field marked secret
MAIN-CONFIG-0005Checkmainsdk check, before deployNo main.yaml in the project folder
MAIN-CONFIG-0010Checkmainsdk check, before deployA layer weakens its parent, for example adds a host main.org.yaml doesn't allow
MAIN-SECRET-0005RuntimeError responses and logsA secret: reference names a secret that isn't set
MAIN-SECRET-0006RuntimeError responses and logsSee the spec
MAIN-SECRET-0007RuntimeError responses and logsSecrets provider unreachable; serving the last good value from memory
MAIN-SECRET-0008RuntimeError responses and logsSee the spec
MAIN-SECRET-0009RuntimeError responses and logsSee the spec
MAIN-SECRET-0010RuntimeError responses and logsSee the spec
MAIN-SECRET-0012RuntimeError responses and logsSee the spec
MAIN-SECRET-0013RuntimeError responses and logsSee the spec
MAIN-CONFIG-0001RuntimeError responses and logsUnsupported schema value in main.yaml
MAIN-CONFIG-0002RuntimeError responses and logsmain.yaml can't be read: bad indent, a tab, an anchor or tag, or a duplicate key; the error names the line
MAIN-CONFIG-0003RuntimeError responses and logsA value has the wrong format: money ("$0.05"), a rate ($5/day), a size (25MB) or a duration (1h)

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
Server-only secrets.get with last-good cachingPhase 10.2config-secrets#secrets-api
secret references in main.yamlPhase 10.2config-secrets#secret-refs
AWS, Google and Azure driversPhase 10.2config-secrets#secrets-cloud-drivers
Vault, Doppler and Infisical driversPhase 10.2config-secrets#secrets-vault-drivers
Secret leak checkPhase 10.2config-secrets#secret-gate
Required env var names checked in the platform buildPhase 10.4deploy#preflight

Quick start

main.yaml
secrets:
  driver: env                  # process environment; the default
email:
  driver: resend
  key: secret:RESEND_API_KEY   # resolved on the server when the email driver starts

Set the value in the environment of the process that runs the app, such as your host's secret settings or your shell in development:

bash
RESEND_API_KEY=re_xxx npx mainsdk dev

No code calls a secrets function for this. The driver receives the value from the reference, and the SDK redacts it from every log record and error as [redacted:secret]. For a provider that is not the environment, the app reads every referenced name once at start with await app.loadSecrets(), so secret:NAME resolves from the first request.

Configuration

main.yaml
secrets:
  driver: env

email:
  key: secret:RESEND_API_KEY
pay:
  stripe: { key: secret:STRIPE_SECRET_KEY, webhook: secret:STRIPE_WEBHOOK_SECRET }
KeyMeaning
secrets.driverWhere values come from: env (default), local, vault, aws-secrets-manager, gcp-secret-manager, azure-key-vault, doppler or infisical.
secrets.ttlHow long a value is served from memory before the provider is asked again. Default 5m.
secrets.<driver>The driver's own settings, such as vault: { address, auth, role, mount }, aws: { region }, gcp: { project }, azure: { vault }, doppler: { project, config }, infisical: { project, env }.

A field named key, secret, token, password, apiKey or clientSecret, in any block, must be a reference. Rules:

  • A reference is secret:NAME, for example secret:RESEND_API_KEY. NAME is the environment variable.
  • A literal in such a field fails with MAIN-CONFIG-0004 and a fix that names the field: Move the value to your secrets manager and write email.key: secret:NAME.
  • A reference to a name that is not set fails with MAIN-SECRET-0005 when a driver asks for it.
  • Resolved values are registered for redaction in logs, errors and telemetry.

Where a block has a documented default reference, you can leave the key out: the email, pay, files and AI blocks each name theirs (for example secret:STRIPE_SECRET_KEY) on their own pages.

What you get

  • secret:NAME references resolved on the server when a driver needs it. Use it for every credential a driver needs.
  • No literal key in main.yaml: the config load refuses it, naming the file and field.
  • mainsdk check finds high-confidence key patterns in source (Stripe live keys, AWS access keys, model provider keys, GitHub tokens, private keys) and committed .env files other than .env.example.
  • Every resolved value is redacted from logs.
  • Free on every plan.

Examples

Keep a key out of source

mainsdk check stops this:

lib/pay.ts
const key = "sk_live_abcdefgh12345678";
text
critical MAIN-SECRET-0002  lib/pay.ts:1  Found a Stripe live key in source code
         fix: Move the key to your secrets manager and reference it as secret:NAME in main.yaml

Write key: secret:STRIPE_SECRET_KEY under pay.stripe in main.yaml instead, and set the variable in the environment.

Catch a literal in main.yaml

main.yaml, refused
email:
  driver: resend
  key: re_live_literal   # refused
text
critical MAIN-CONFIG-0004  main.yaml:1  main.yaml: email.key is a literal value
         fix: Move the value to your secrets manager and write email.key: secret:NAME

Use different sources per environment

Overlays set the driver per environment. Today every environment reads the process environment, so you set different values per environment in your host. Other drivers will change only this line:

main.env.prod.yaml
secrets:
  driver: env

See main.yaml for overlay order.

Reference: read a secret

secrets.get(name) returns a secret's value on the server. A value is served from memory for secrets.ttl, then read again, so a rotated value arrives without a restart. When the provider is unreachable after a value was read, the last good value is served from memory and MAIN-SECRET-0007 is logged once per outage. Nothing is written to disk. secrets.raw() returns the provider's own settings; each use is logged and check lists it as bypassing the interface's guarantees.

app/api/partner/route.ts
import { secrets } from "mainsdk/secrets";   // server only

const token = await secrets.get("PARTNER_API_TOKEN");

mainsdk/secrets resolves to a module that fails type checking and throws MAIN-SECRET-0001 under the browser export condition, and mainsdk check fails a client component (a 'use client' file, or a module one imports) that imports it. After a build, check scans .next/static, out, build/static, dist/client, dist/assets and dist/static for the value of each secret: reference and fails with MAIN-SECRET-0004 and the chunk name.

secrets.all(name) returns the current value first and the previous one second, only while less than rotation.overlap has passed since the rotation was seen. Verify a signature by trying each in order, and sign with the current value only.

secrets.age(name) returns { version, rotatedAt?, maxAge, stale }: the version in use, when it was rotated, the longest age allowed and whether it is past it. secrets.watch(name, fn) calls fn with the name and version when a secret's version changes, never with the value; pass "*" to watch every name. It returns a function that stops watching.

Reference: resolve a reference

resolveSecret(ref, source?) turns a secret:NAME reference, or a bare NAME, into its value. The default source reads the process environment. It throws MAIN-SECRET-0005 when the name is unset or empty. Use it in scripts and tests. In app code, drivers get values through the app, which also registers each value for log redaction; resolveSecret alone does not.

scripts/check-key.ts
import { resolveSecret } from "mainsdk";

const key = resolveSecret("secret:RESEND_KEY");                       // reads process.env.RESEND_KEY
const fake = resolveSecret("RESEND_KEY", (name) => ({ RESEND_KEY: "re_test" })[name]);

Planned

  • Rotation, watchers and age limits: spec secret-rotation, planned for 0.3.

Errors and checks

CodeWhenFix
MAIN-CONFIG-0004A literal value in a field marked secret in main.yaml, with file and fieldReplace it with secret:NAME
MAIN-SECRET-0002A high-confidence key pattern in source, with file and lineMove the value to the environment and use a secret: reference
MAIN-SECRET-0003A committed .env file other than .env.examplegit rm --cached it and rotate the keys it held
MAIN-SECRET-0005A secret: reference names a secret that is not setSet it in your environment or secrets manager
MAIN-SECRET-0001A client component imports mainsdk/secrets, or a browser bundle loads itRead the secret in server code and pass the result on
MAIN-SECRET-0004A resolved secret: value is in a browser bundle chunkKeep the secrets.get call on the server and rebuild
MAIN-SECRET-0006A secret name that is not UPPER_SNAKE, or a driver setting is missingFix the name or the secrets.<driver> block
MAIN-SECRET-0007The provider is unreachable and the last good value is servedRestore the provider
MAIN-SECRET-0008The provider is unreachable and no earlier value is heldCheck the provider and its host
MAIN-SECRET-0009The provider refused the app's identityCheck the role, token and access policy

mainsdk check runs the secrets gate on main.yaml, source and committed files, and each finding names the file, the line and the fix. See the check for gates and exit codes, and check codes.

Swapping drivers

Change secrets.driver in main.yaml; the drivers are:

DriverReads fromCredentials
envThe process environmentNone
localA git-ignored .mainsdk/secrets.jsonNone
vaultKV v2 at secrets.vault.addressVAULT_TOKEN, or the Kubernetes service account or a JWT (auth: workload-identity, jwt)
aws-secrets-managerGetSecretValue, signed with SigV4Web identity (AWS_WEB_IDENTITY_TOKEN_FILE, AWS_ROLE_ARN), or access keys in the environment
gcp-secret-managerThe latest version of the secretThe metadata server token, or GOOGLE_OAUTH_ACCESS_TOKEN
azure-key-vaulthttps://<vault>.vault.azure.net (underscores become dashes)Managed identity, or AZURE_ACCESS_TOKEN
dopplerThe Doppler APIDOPPLER_TOKEN
infisicalThe Infisical APIKubernetes auth (INFISICAL_IDENTITY_ID), universal auth, or INFISICAL_TOKEN

Each driver's hosts are allowed by the network layer without a network.allow entry. Your secret:NAME references do not change when the driver does: only the names must exist in the new store. Reading in place means nothing is copied out of your manager.

Testing

createTestApp() from @mainsdk/testing takes the secret values a test needs, so a test never reads the real environment.

lib/email.test.ts
import assert from "node:assert/strict";
import { it } from "node:test";
import { createTestApp } from "@mainsdk/testing";

it("secret references resolve from the test secrets", () => {
  const app = createTestApp({
    yaml: "schema: 1\nproject: t\nemail:\n  driver: resend\n  key: secret:RESEND_API_KEY\n",
    secrets: { RESEND_API_KEY: "re_test_123456" },
  });
  assert.equal(app.secret("secret:RESEND_API_KEY"), "re_test_123456");
  assert.throws(() => app.secret("secret:MISSING"), { code: "MAIN-SECRET-0005" });
});

Security and privacy

  • Values never appear in main.yaml, which is safe to commit.
  • Resolved values are redacted from logs, errors and telemetry.
  • mainsdk check blocks keys in source and committed env files.
  • secrets.get() is server only, and last good values are held in memory only, never on disk.

FAQ

Can I keep using environment variables? Yes. The env driver is the default. You still refer to keys as secret:NAME, so adding a manager later does not change your code.

Is .env.example allowed? Yes. Only other committed .env files fail the check.

How do I read a secret in my own code? Call secrets.get("NAME") in server code. To give a sandbox run a secret, list it under sandbox.secrets and pass secret("NAME").

What about encryption keys for records? Those belong to Encryption keys, which keeps key material inside your KMS.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet