---
title: "Secrets"
description: "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."
section: "interfaces"
status: "Building for 0.2 · ships as a stable API"
spec: "config-secrets"
canonical: "https://mainsdk.dev/docs/interfaces/secrets"
updated: "2026-10-28"
---

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

Status: Building for 0.2 · ships as a stable API · Canonical: https://mainsdk.dev/docs/interfaces/secrets · 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`.

> **Note:** `secrets` is in 0.2 (spec `config-secrets`). Built: `secret:NAME` references, `secrets.get()`, the `env`, `local`, `vault`, `aws-secrets-manager`, `gcp-secret-manager`, `azure-key-vault`, `doppler` and `infisical` drivers, the last good value through an outage, and the checks for a literal in `main.yaml`, a key in source, a committed `.env` file, a client import and a secret in a browser bundle. Rotation is its own spec, `secret-rotation`, planned for 0.3.

## At a glance

- Status: Building for 0.2 · ships as a stable API
- Phase: 1 (Core, public beta)
- Milestone: 0.2 Core loop
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/config-secrets.md
- Category: Security and privacy

Calls:

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

In main.yaml:

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

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

Pick one in main.yaml with `secrets.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| HashiCorp Vault | `vault` | yes |  |
| AWS Secrets Manager | `aws-secrets-manager` |  |  |
| Google Secret Manager | `gcp-secret-manager` |  |  |
| Azure Key Vault | `azure-key-vault` |  |  |
| Doppler | `doppler` |  |  |
| Infisical | `infisical` |  |  |
| Environment variables | `env` |  |  |
| Local file | `local` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-SECRET-0001` | check | `mainsdk/secrets` imported from client code |
| `MAIN-SECRET-0002` | check | A key pattern in source code |
| `MAIN-SECRET-0003` | check | A committed `.env` file other than `.env.example` |
| `MAIN-SECRET-0004` | check | A value from a `secret:` reference is in a browser bundle |
| `MAIN-SECRET-0011` | check | 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` | check | 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` | check | A literal value in a field marked secret |
| `MAIN-CONFIG-0005` | check | No `main.yaml` in the project folder |
| `MAIN-CONFIG-0010` | check | A layer weakens its parent, for example adds a host `main.org.yaml` doesn't allow |
| `MAIN-SECRET-0005` | runtime | A `secret:` reference names a secret that isn't set |
| `MAIN-SECRET-0006` | runtime |  |
| `MAIN-SECRET-0007` | runtime | Secrets provider unreachable; serving the last good value from memory |
| `MAIN-SECRET-0008` | runtime |  |
| `MAIN-SECRET-0009` | runtime |  |
| `MAIN-SECRET-0010` | runtime |  |
| `MAIN-SECRET-0012` | runtime |  |
| `MAIN-SECRET-0013` | runtime |  |
| `MAIN-CONFIG-0001` | runtime | Unsupported `schema` value in `main.yaml` |
| `MAIN-CONFIG-0002` | runtime | `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` | runtime | A value has the wrong format: money (`"$0.05"`), a rate (`$5/day`), a size (`25MB`) or a duration (`1h`) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Server-only secrets.get with last-good caching | 1 | 0.2 | `config-secrets#secrets-api` |
| secret references in main.yaml | 1 | 0.2 | `config-secrets#secret-refs` |
| AWS, Google and Azure drivers | 1 | 0.2 | `config-secrets#secrets-cloud-drivers` |
| Vault, Doppler and Infisical drivers | 1 | 0.2 | `config-secrets#secrets-vault-drivers` |
| Secret leak check | 1 | 0.2 | `config-secrets#secret-gate` |
| Required env var names checked in the platform build | 1 | 0.4 | `deploy#preflight` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="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

```yaml title="main.yaml"
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 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:

```ts title="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

```text title="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:

```yaml title="main.env.prod.yaml"
secrets:
  driver: env
```

See [main.yaml](https://mainsdk.dev/docs/concepts/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.

```ts title="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.

```ts title="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

| 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](https://mainsdk.dev/docs/concepts/the-check) for gates and exit codes, and [check codes](https://mainsdk.dev/docs/reference/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`](https://mainsdk.dev/docs/interfaces/testing) takes the secret values a test needs, so a test never reads the real environment.

```ts title="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](https://mainsdk.dev/docs/interfaces/sandbox) run a secret, list it under `sandbox.secrets` and pass `secret("NAME")`.

**What about encryption keys for records?** Those belong to [Encryption keys](https://mainsdk.dev/docs/interfaces/keys), which keeps key material inside your KMS.

## Next

- [Runtime settings](https://mainsdk.dev/docs/interfaces/config): typed settings that change without a deploy.
- [Encryption keys](https://mainsdk.dev/docs/interfaces/keys): envelope encryption with your KMS.
- [main.yaml](https://mainsdk.dev/docs/concepts/main-yaml): references, overlays and inheritance.
