Secrets
secretsRefer 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.
config-secretsFree in your app, Apache-2.0Status 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.
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_KEYimport { secrets } from "mainsdk"; // server only: a client import fails the build
const token = await secrets.get("PARTNER_API_TOKEN");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" });
});Drivers
8 drivers. Select one to see its line in main.yaml.
secrets:
driver: vaultChange this one line to swap providers; your calls stay the same.HashiCorp Vault integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-SECRET-0001 | Checkmainsdk check, before deploy | mainsdk/secrets imported from client code |
MAIN-SECRET-0002 | Checkmainsdk check, before deploy | A key pattern in source code |
MAIN-SECRET-0003 | Checkmainsdk check, before deploy | A committed .env file other than .env.example |
MAIN-SECRET-0004 | Checkmainsdk check, before deploy | A value from a secret: reference is in a browser bundle |
MAIN-SECRET-0011 | Checkmainsdk check, before deploy | A 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-0014 | Checkmainsdk check, before deploy | A 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-0004 | Checkmainsdk check, before deploy | A literal value in a field marked secret |
MAIN-CONFIG-0005 | Checkmainsdk check, before deploy | No main.yaml in the project folder |
MAIN-CONFIG-0010 | Checkmainsdk check, before deploy | A layer weakens its parent, for example adds a host main.org.yaml doesn't allow |
MAIN-SECRET-0005 | RuntimeError responses and logs | A secret: reference names a secret that isn't set |
MAIN-SECRET-0006 | RuntimeError responses and logs | See the spec |
MAIN-SECRET-0007 | RuntimeError responses and logs | Secrets provider unreachable; serving the last good value from memory |
MAIN-SECRET-0008 | RuntimeError responses and logs | See the spec |
MAIN-SECRET-0009 | RuntimeError responses and logs | See the spec |
MAIN-SECRET-0010 | RuntimeError responses and logs | See the spec |
MAIN-SECRET-0012 | RuntimeError responses and logs | See the spec |
MAIN-SECRET-0013 | RuntimeError responses and logs | See the spec |
MAIN-CONFIG-0001 | RuntimeError responses and logs | Unsupported schema value in main.yaml |
MAIN-CONFIG-0002 | RuntimeError responses and logs | main.yaml can't be read: bad indent, a tab, an anchor or tag, or a duplicate key; the error names the line |
MAIN-CONFIG-0003 | RuntimeError responses and logs | A 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.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Server-only secrets.get with last-good caching | Phase 1 | 0.2 | config-secrets#secrets-api |
| secret references in main.yaml | Phase 1 | 0.2 | config-secrets#secret-refs |
| AWS, Google and Azure drivers | Phase 1 | 0.2 | config-secrets#secrets-cloud-drivers |
| Vault, Doppler and Infisical drivers | Phase 1 | 0.2 | config-secrets#secrets-vault-drivers |
| Secret leak check | Phase 1 | 0.2 | config-secrets#secret-gate |
| Required env var names checked in the platform build | Phase 1 | 0.4 | deploy#preflight |
Quick start
secrets:
driver: env # process environment; the default
email:
driver: resend
key: secret:RESEND_API_KEY # resolved on the server when the email driver startsSet the value in the environment of the process that runs the app, such as your host's secret settings or your shell in development:
RESEND_API_KEY=re_xxx npx mainsdk devNo 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
secrets:
driver: env
email:
key: secret:RESEND_API_KEY
pay:
stripe: { key: secret:STRIPE_SECRET_KEY, webhook: secret:STRIPE_WEBHOOK_SECRET }| Key | Meaning |
|---|---|
secrets.driver | Where values come from: env (default), local, vault, aws-secrets-manager, gcp-secret-manager, azure-key-vault, doppler or infisical. |
secrets.ttl | How 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 examplesecret:RESEND_API_KEY.NAMEis the environment variable. - A literal in such a field fails with
MAIN-CONFIG-0004and 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-0005when 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:NAMEreferences 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 checkfinds high-confidence key patterns in source (Stripe live keys, AWS access keys, model provider keys, GitHub tokens, private keys) and committed.envfiles 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:
const key = "sk_live_abcdefgh12345678";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.yamlWrite 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
email:
driver: resend
key: re_live_literal # refusedcritical 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:NAMEUse 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:
secrets:
driver: envSee 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.
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.
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
| Code | When | Fix |
|---|---|---|
MAIN-CONFIG-0004 | A literal value in a field marked secret in main.yaml, with file and field | Replace it with secret:NAME |
MAIN-SECRET-0002 | A high-confidence key pattern in source, with file and line | Move the value to the environment and use a secret: reference |
MAIN-SECRET-0003 | A committed .env file other than .env.example | git rm --cached it and rotate the keys it held |
MAIN-SECRET-0005 | A secret: reference names a secret that is not set | Set it in your environment or secrets manager |
MAIN-SECRET-0001 | A client component imports mainsdk/secrets, or a browser bundle loads it | Read the secret in server code and pass the result on |
MAIN-SECRET-0004 | A resolved secret: value is in a browser bundle chunk | Keep the secrets.get call on the server and rebuild |
MAIN-SECRET-0006 | A secret name that is not UPPER_SNAKE, or a driver setting is missing | Fix the name or the secrets.<driver> block |
MAIN-SECRET-0007 | The provider is unreachable and the last good value is served | Restore the provider |
MAIN-SECRET-0008 | The provider is unreachable and no earlier value is held | Check the provider and its host |
MAIN-SECRET-0009 | The provider refused the app's identity | Check 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:
| Driver | Reads from | Credentials |
|---|---|---|
env | The process environment | None |
local | A git-ignored .mainsdk/secrets.json | None |
vault | KV v2 at secrets.vault.address | VAULT_TOKEN, or the Kubernetes service account or a JWT (auth: workload-identity, jwt) |
aws-secrets-manager | GetSecretValue, signed with SigV4 | Web identity (AWS_WEB_IDENTITY_TOKEN_FILE, AWS_ROLE_ARN), or access keys in the environment |
gcp-secret-manager | The latest version of the secret | The metadata server token, or GOOGLE_OAUTH_ACCESS_TOKEN |
azure-key-vault | https://<vault>.vault.azure.net (underscores become dashes) | Managed identity, or AZURE_ACCESS_TOKEN |
doppler | The Doppler API | DOPPLER_TOKEN |
infisical | The Infisical API | Kubernetes 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.
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 checkblocks 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
- Runtime settings: typed settings that change without a deploy.
- Encryption keys: envelope encryption with your KMS.
- main.yaml: references, overlays and inheritance.
Was this page helpful?