Managing secrets in Mastra agents
Frameworks
Mastra reads provider keys from the environment. For local development the whole fix is one command and no code change:
seekrit secrets import .env --app support --env development && rm .env
seekrit run -- mastra dev
Mastra also types model as a function of requestContext, which is what
makes per-tenant credentials a supported feature rather than a workaround.
This post covers both ends and the three setups between them.
Setup 1: wrap the process
seekrit run -- mastra dev
seekrit run -- pnpm dev
Secrets land in the child process only. seekrit run still overlays a .env
if one exists, and the inherited process environment wins over both, so
migrating is safe. Leaving the file behind defeats the point. Delete it.
Setup 2: resolve in code
For a deployed Mastra server, resolve before the agent is constructed:
import { Seekrit } from "@seekrit/sdk";
import { Agent } from "@mastra/core/agent";
const secrets = await new Seekrit().resolve();
export const supportAgent = new Agent({
id: "support",
name: "Support Agent",
instructions: "You are a helpful support agent",
model: { id: "openai/gpt-5.6-sol", apiKey: secrets.OPENAI_API_KEY },
});
The SDK is WebCrypto and fetch only. The same code runs on Node, Bun, Deno,
and Cloudflare Workers. A Worker has no ambient environment, so pass the token
explicitly: new Seekrit({ token: env.SEEKRIT_TOKEN }).
Setup 3: the process never holds the key
Mastra's object model form takes a url. Point it at the
seekrit proxy and pass a placeholder:
model: {
id: "custom/gpt-5.6-sol",
url: "http://127.0.0.1:8080/openai/v1",
apiKey: "{{seekrit:OPENAI_API_KEY}}",
},
# seekrit-proxy.toml
listen = "127.0.0.1:8080"
[[route]]
prefix = "/openai"
upstream = "https://api.openai.com"
allow = ["OPENAI_API_KEY"]
methods = ["POST"]
paths = ["/v1/**"]
Note the paths. @ai-sdk/openai calls /v1/responses, not
/v1/chat/completions. An allowlist copied from an older example that pins
/v1/chat/completions denies every request from this stack. Use /v1/** or
pin /v1/responses on purpose.
Without running the proxy
Mastra accepts an AI SDK provider instance anywhere it accepts a model
string, so the fetch shim drops in as a model:
import { createOpenAI } from "@ai-sdk/openai";
import { seekritFetch } from "@seekrit/sdk/fetch";
const openai = createOpenAI({
apiKey: "{{seekrit:OPENAI_API_KEY}}",
fetch: seekritFetch({ allow: { "api.openai.com": ["OPENAI_API_KEY"] } }),
});
export const supportAgent = new Agent({ /* … */ model: openai("gpt-5.6-sol") });
This runs in your process, so it is a weaker boundary than the proxy. The in-process injection page sets out the trade-off. It is also what setups 4 and 5 build on.
Setup 4: a different key per request
@seekrit/sdk/mastra returns Mastra's function-form model. The builder runs
per request, and the fetch behind it substitutes the value that request's
tenant resolves:
import { createOpenAI } from "@ai-sdk/openai";
import { seekritModel } from "@seekrit/sdk/mastra";
model: seekritModel(
({ apiKey, fetch }) => createOpenAI({ apiKey, fetch })("gpt-5.6-sol"),
{
secret: "OPENAI_API_KEY",
allow: { "api.openai.com": ["OPENAI_API_KEY"] },
scope: (rc) => ({ with: { tenants: String(rc.get("tenant")) } }),
},
),
The fetch is shared per scope, not per request. A fresh one per request
would resolve on every request and undo the cache. maxScopes (default 64)
bounds how many tenants' resolved sets are held in memory.
Populate the request context from your own edge:
import { seekritRequestContext } from "@seekrit/sdk/mastra";
server: { middleware: [seekritRequestContext({ header: "x-tenant" })] },
That middleware trusts the header. Use it behind your own authenticated edge. Facing the internet, set the key from a verified session in your own middleware, because a caller who can choose the header can choose the tenant.
Setup 5: a key one tool may spend
A provider key buys tokens. A Stripe key refunds money. Mastra passes
requestContext to createTool executors, so a tool can get a fetch
narrowed to its own secrets and nothing else:
import { createTool } from "@mastra/core/tools";
import { seekritToolFetch } from "@seekrit/sdk/mastra";
const refundFetch = seekritToolFetch({
allow: { "api.stripe.com": ["STRIPE_SECRET_KEY"] },
only: ["STRIPE_SECRET_KEY"],
scope: (rc) => ({ with: { tenants: String(rc.get("tenant")) } }),
label: "tool:refund",
});
export const refund = createTool({
id: "refund",
description: "Refund a charge",
inputSchema: z.object({ chargeId: z.string() }),
execute: async ({ chargeId }, context) => {
const response = await refundFetch(context)("https://api.stripe.com/v1/refunds", {
method: "POST",
headers: { authorization: "Bearer {{seekrit:STRIPE_SECRET_KEY}}" },
body: new URLSearchParams({ charge: chargeId }),
});
return response.ok ? "refunded" : `refund failed: ${response.status}`;
},
});
only is a ceiling on the tool. It applies whether or not a request context
arrived. Build the fetch at module scope. Building it inside execute gives
every call its own cache and its own resolve.
Which setup for which credential
| Credential | Setup |
|---|---|
| Model key, local development | 1 |
| Model key, your own deployment | 2, or 3 if the agent runs generated code |
| One agent, many tenants | 4 |
| A tool credential (Stripe, GitHub, a database) | 5, or 3 with paths set |
Two more things that go wrong. A long-running workflow holds whatever it
resolved at start, so a step that needs a credential that may rotate should
resolve inside the step. And the seekrit token does not belong in .env
either. On a developer machine seekrit login stores it in
~/.config/seekrit/config.json. In a deploy it is the one variable the
platform holds.
Setup
Three commands put the keys in an environment, mint a token bound to it, and
export SEEKRIT_TOKEN. The
frameworks guide has
them. Values are encrypted before they are stored, so the service holding
them cannot read them. The full Mastra page is
at /docs/guides/frameworks/mastra.