Use promo code BETATEST1 for full access
seekrit
← all posts

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

CredentialSetup
Model key, local development1
Model key, your own deployment2, or 3 if the agent runs generated code
One agent, many tenants4
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.