Secrets in durable agent workflows: Inngest and Trigger.dev
How-to
Inngest and Trigger.dev solve the same problem for agents: a run that takes
twenty minutes, calls a model eleven times, waits for a human, and survives a
deploy in the middle. They do it by recording. Inngest memoizes every
step.run result and replays the function from the top on each resume;
Trigger.dev stores every task's payload and output and shows them in the
dashboard. Durability is a log of everything that passed through a step
boundary.
That is the property to design around. A secret that crosses a step boundary is stored, by design, on someone else's infrastructure, for as long as the run history is kept. Trigger.dev's documentation says it directly: never pass secrets in the task payload, because payloads are logged and visible in the dashboard. The same is true of outputs, and on Inngest of anything a step returns.
The anti-pattern
This looks reasonable and stores your whole environment at Inngest:
// Do not do this.
const secrets = await step.run("resolve-secrets", () =>
new Seekrit().resolve(),
);
step.run exists so that expensive work is not repeated on replay. Its return
value is serialized and sent to Inngest to make that possible. Every value in
the environment is now in the run's event log.
The Trigger.dev version is a subtask that resolves and returns, or a parent
that resolves and passes the values in a triggerAndWait payload. Same
result: the values are in the run record.
Rule 1: resolve outside a step, or hold a placeholder
On Inngest, code outside step.run executes on every replay, which is
usually the reason to put things inside one. For a resolve it is the reason to
keep it outside. The SDK caches in memory, so replays in the same process do
not pay again, and nothing is sent to Inngest:
import { inngest } from "./client";
import { Seekrit } from "@seekrit/sdk";
const seekrit = new Seekrit(); // token from SEEKRIT_TOKEN
export const supportAgent = inngest.createFunction(
{ id: "support-agent" },
{ event: "ticket/opened" },
async ({ event, step }) => {
const secrets = await seekrit.resolve(); // outside any step
const charge = await step.run("lookup-charge", async () => {
const res = await fetch(`https://api.stripe.com/v1/charges/${event.data.charge}`, {
headers: { authorization: `Bearer ${secrets.STRIPE_KEY}` },
});
const body = await res.json();
return { amount: body.amount, status: body.status }; // fields, not the response
});
// ...
},
);
Two things in that snippet matter. The resolve is outside the step, and the step returns the two fields the next step needs rather than the response object, because the response object is what gets stored.
The stronger version removes the value from the function entirely. The placeholder is what gets closed over, logged, and stored, and it is inert anywhere but the one allowlisted request:
import { seekritFetch } from "@seekrit/sdk/fetch";
const fetch = seekritFetch({
rules: [
{ host: "api.stripe.com", methods: ["GET"], paths: ["/v1/charges/*"], allow: ["STRIPE_KEY"] },
{ host: "api.openai.com", methods: ["POST"], paths: ["/v1/**"], allow: ["OPENAI_API_KEY"] },
],
});
const charge = await step.run("lookup-charge", async () => {
const res = await fetch(`https://api.stripe.com/v1/charges/${event.data.charge}`, {
headers: { authorization: "Bearer {{seekrit:STRIPE_KEY}}" },
});
const body = await res.json();
return { amount: body.amount, status: body.status };
});
Now a step that accidentally returns its request headers stores
{{seekrit:STRIPE_KEY}}. The rules also bound what the key can do: a refund is
a POST, and the rule allows GET, so a prompt-injected tool call is refused
inside your process with a 403 that names the rule, before Inngest or Stripe
sees it. This is in-process injection,
a weaker boundary than a separate proxy and a much stronger one than a value in
process.env.
Rule 2: know who makes the model call
Inngest's step.ai.infer is built for agents on serverless: the function
hands Inngest the request, Inngest calls the provider, and the function is not
running while the model thinks. That means the request, including its
Authorization header, is sent to Inngest's infrastructure, which makes the
call. Inngest states that it never logs or stores API keys. It is still a
credential leaving your process for a third party's, which a placeholder
cannot help with, because the substitution has to happen where the request is
made.
Decide per call:
step.ai.inferwhen you want the serverless economics and accept that the provider key transits Inngest. Give it a key from a resolve outside a step, never from a step's output, and prefer a key from a dedicated seekrit environment holding nothing else.step.ai.wraparound an AI SDK call when the key should stay in your process. The call originates from your function, soseekritFetchworks and the key is a placeholder everywhere except the one request:
import { createOpenAI } from "@ai-sdk/openai";
import { generateText } from "ai";
const openai = createOpenAI({ apiKey: "{{seekrit:OPENAI_API_KEY}}", fetch });
const { text } = await step.ai.wrap("classify", generateText, {
model: openai("gpt-5.6-terra"),
prompt: event.data.body,
});
AgentKit's model adapters take apiKey directly and run through step.ai.infer
when the network executes inside an Inngest function, so an AgentKit agent is
in the first category. Resolve outside a step and pass the value in.
Trigger.dev
Trigger.dev runs tasks as long-lived Node processes on its own workers, with the environment set in the dashboard or programmatically at deploy. Two ways to get seekrit into that.
Sync at deploy with the syncEnvVars build extension. It runs on every
deploy, fetches from a source you name, and writes the result into Trigger.dev's
environment variable store:
// trigger.config.ts
import { defineConfig } from "@trigger.dev/sdk";
import { syncEnvVars } from "@trigger.dev/build/extensions/core";
import { Seekrit } from "@seekrit/sdk";
export default defineConfig({
project: "proj_abc",
build: {
extensions: [
syncEnvVars(async () => new Seekrit({ token: process.env.SEEKRIT_TOKEN }).resolve()),
],
},
});
Zero code change in the tasks. The cost is that every value is copied to Trigger.dev, and a rotation in seekrit lands on the next deploy, not before.
One variable and resolve in the task, which keeps the values out of
Trigger.dev's store. Set SEEKRIT_TOKEN in the dashboard and nothing else:
import { task } from "@trigger.dev/sdk";
import { Seekrit } from "@seekrit/sdk";
const seekrit = new Seekrit();
export const supportAgent = task({
id: "support-agent",
run: async (payload: { ticketId: string }) => {
const secrets = await seekrit.resolve();
// ... use secrets; return fields, not credentials
return { resolution: "refunded" };
},
});
The same two rules apply. Do not return a credential from run, because the
output is stored. Do not pass one in a payload to a subtask, because the
payload is stored. With seekritFetch and placeholders, both mistakes become
harmless, since the thing stored is the placeholder.
Checkpoints outlive rotations
There is one more consequence of the recording. A run that stored a credential
in a step output in March still has it in June, after you rotated the key,
because rotation changes the value in seekrit and not the value in the run
history. A checkpoint is a backup of your API keys
covers the general case. Placeholders are the fix here too: a stored
{{seekrit:STRIPE_KEY}} resolves to whatever the key is now, and only through
the allowlist.
Summary
| Stored by the platform | Do | |
|---|---|---|
Inngest step.run return value | yes | Resolve outside the step; return fields, not responses |
Inngest step.ai.infer request | key transits, not stored per Inngest | Dedicated environment for that key; or step.ai.wrap with a placeholder |
| Trigger.dev payload and output | yes, visible in the dashboard | Never pass or return a credential |
Trigger.dev syncEnvVars | every synced value | Prefer one SEEKRIT_TOKEN and resolve in the task |
Placeholders through seekritFetch make most rows moot: what the platform
stores is a string that only works toward hosts and operations you allowlisted,
from inside your process. The in-process guide
has the options, and the agent proxy is the
separate-process version for workloads you trust less than your own tasks.