seekrit
← all posts

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.infer when 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.wrap around an AI SDK call when the key should stay in your process. The call originates from your function, so seekritFetch works 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 platformDo
Inngest step.run return valueyesResolve outside the step; return fields, not responses
Inngest step.ai.infer requestkey transits, not stored per InngestDedicated environment for that key; or step.ai.wrap with a placeholder
Trigger.dev payload and outputyes, visible in the dashboardNever pass or return a credential
Trigger.dev syncEnvVarsevery synced valuePrefer 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.