Genkit security guide

Secure Genkit with inbound screening, authored-tool wrappers, and middleware.

7 min read
In short: Protect Genkit flows with inbound screening, authored-tool wrappers, and middleware for unwrapped tools. Keep policy, resource authorization, human approval, and capture separate.

How do you secure Genkit?

To secure a Genkit application, screen inbound text before generation, wrap authored ToolAction handlers, and use middleware for unwrapped tools. Resolve identity from your server and authorize each requested resource before execution. Keep human approval, runtime policy, and tool-result capture separate so an observation cannot be mistaken for enforcement.

Use this guide on Genkit JS 1.41.x (genkit >=1.0.0 <2). Stay on Genkit 1.33 or later if you use middleware that hooks tool calls.

Genkit is Google's framework for building AI flows: model calls, tools, and multi-step generation with tracing built in. When you call ai.generate() or chat.send(), the model may invoke tools you defined or tools injected from the filesystem or Model Context Protocol (MCP). Untrusted prompt text can steer those calls the same way it can in any agent stack.

Arcjet Guard is a runtime policy layer for AI agents. You define rules – prompt-injection detectors, rate limits, PII checks, allowlists – and Guard allows or denies inbound text and tool calls before side effects run. The following examples use Guard for both controls. They wrap Genkit defineTool actions – not Go or Python Genkit, and not Vercel AI SDK tool() handlers.

HTTP request protection on a route doesn't see generate() – you still need runtime controls inside the flow.

Use this checklist to review your application:

  • Patch and lock dependencies. Stay on Genkit 1.33 or later if you use middleware.
  • Keep API keys off the client and out of flow traces.
  • Screen user text before ai.generate() or chat.send().
  • Don't treat interrupt(), defineInterrupt, or toolApproval as a policy gate.
  • Wrap the returned ToolAction, not the inner defineTool handler.
  • Gate tools that you didn't wrap with generate middleware.
  • Validate arguments. Prefer omitting outputSchema on guarded tools.
  • Catch issues in the editor and in CI before they ship.

For the Guard helpers used in the examples, see the Genkit agent guard.

How do you keep Genkit dependencies safe?

Apply patches on a schedule you actually keep, and out of band for a critical advisory. Commit a lockfile so every environment installs the same graph, and turn on Dependabot, Renovate, or an equivalent bot for npm. Run npm audit (or your package manager's audit) in CI and fail on known high-severity issues you haven't waived.

Pin genkit and every model plugin (@genkit-ai/googleai, @genkit-ai/vertexai) together – a plugin major that doesn't match the core package is a silent runtime miss. Treat install scripts and added network access as review events. Prefer a short function that you maintain over a trivial dependency. See trivial packages.

How do you manage Genkit secrets?

Model and gateway keys such as GEMINI_API_KEY and GOOGLE_APPLICATION_CREDENTIALS belong on the server, loaded at process start. Don't put them in a client bundle, a flow input that later lands in a trace, or a context object that you serialize for debugging.

Genkit traces can include tool input and output. Redact before export. See redacting sensitive data from logs and storing secrets in environment variables.

How do you screen inbound Genkit text?

Genkit doesn't ship an inbound hook, so screen in your route handler, job, or webhook before you call ai.generate() or chat.send(). That is where user text is still plain – not yet part of the model's context. Middleware model hooks intercept the model call, not user text.

With Guard, call arcjet.guard() with prompt-injection rules on that string. Direct guard() fails open: an ALLOW isn't proof the rules ran, so gate on decision.hasFailedOpen() when this call site must fail closed. On deny, don't call generate().

import { detectPromptInjection } from "@arcjet/guard";
import { genkitContext } from "@arcjet/guard/genkit/v1";
import { arcjet } from "./arcjet.js";
const inbound = detectPromptInjection();
const appContext = { sessionId: conversationId };
const decision = await arcjet.guard({
label: "message.received",
rules: [inbound(userText)],
...genkitContext({ context: appContext }),
});
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) {
throw new Error("message blocked");
}

genkitContext takes { context: appContext }, not a bare { sessionId }. It never mints an ID. It never reads traceId. It never treats interrupt / resumed as correlation.

Why isn't interrupt() a security policy?

Genkit can pause a flow and ask a person to approve a tool call. That is human-in-the-loop: valuable when a reviewer needs context, but not equivalent to a policy that evaluates every invocation automatically.

interrupt(), defineInterrupt, @genkit-ai/middleware toolApproval, and restartTool pause for a person. They are confirmation, not a remote allow or deny. If nobody is watching, or if the UI doesn't surface the arguments, the pause doesn't protect you.

The same pattern appears as Mastra requireApproval, Claude canUseTool, LangGraph interrupt(), and OpenAI Agents needsApproval. See human approval is not a security policy.

Don't turn a deny into interrupt(), ToolInterruptError, or finishReason: "interrupted" – those are pauses for a person, not a policy result.

How do you gate Genkit tools?

After inbound screening, tools are where refunds, emails, and API writes happen. Genkit wraps tool definitions in a ToolAction object. Arcjet hooks that outer callable so denials return structured results the model can read, instead of crashing the flow.

guardTool wraps the ToolAction that ai.defineTool returns. It replaces that callable and .run. It doesn't wrap the inner handler. outputSchema validation runs inside action(), so wrapping the handler would throw on a schema-mismatched denial and fail generate(). Prefer omitting outputSchema on guarded tools.

The helper overwrites the original registry key because generate() converts tools to name and schema defs and looks the live action up. On deny the original action never runs. The model receives an ArcjetDenialResult as a completed toolResponse.output. Don't throw.

guardMiddleware is a { name, instantiate } object with a tool hook. Pass it on ai.generate({ use }). A raw function becomes a model hook only and can't deny. The hook skips branded (guardTool) tools when it can look them up. Tools that can't be looked up are still gated.

MCP and filesystem-injected tools skip an unwrapped handler. guardMiddleware still gates a call that generate() executes. returnToolRequests: true still hits guardTool if the caller invokes the wrapped action.

import { genkit, z } from "genkit";
import { localDetectSensitiveInfo, tokenBucket } from "@arcjet/guard";
import { guardMiddleware, guardTool } from "@arcjet/guard/genkit/v1";
import { arcjet } from "./arcjet.js";
const ai = genkit({/* configure your model plugin */});
// Resolve authenticatedUser from the server session before constructing tools.
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
const detectPii = localDetectSensitiveInfo({
deny: ["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],
});
const lookupOrder = guardTool(
arcjet,
ai.defineTool(
{
name: "lookup_order",
description: "Look up an order by number",
inputSchema: z.object({
orderNumber: z.string(),
note: z.string(),
}),
},
async ({ orderNumber, note }) => ({
orderNumber,
note,
status: "shipped",
}),
),
{
action: "order.looked-up",
rules: (input) => [
lookupLimit({ key: authenticatedUser.id, requested: 1 }),
detectPii(input.note),
],
},
);
await ai.generate({
prompt: userText,
tools: [lookupOrder],
use: [guardMiddleware(arcjet, { sessionId: conversationId })],
context: { sessionId: conversationId },
});

Scan the free-text note. An opaque orderNumber doesn't trip email, phone, card, or IP detection.

The tool-hook ctx from toRunOptions is only { metadata, resumed }. Put the same ID on guardMiddleware({ sessionId }) when you need tool-time correlation.

How do the editor and CI catch Genkit security bugs?

Turn on TypeScript strict, ESLint, and secret scanning in the editor. Trunk, Semgrep, TruffleHog, and Gitleaks all have editor plugins and CI jobs. Run the same scanners in CI that you run locally – an editor warning that isn't a CI failure will be ignored.

Fail the build on secrets in the diff and on known vulnerable dependencies. None of these replace a review of the tools that are registered and the tools that you actually wrapped. They catch the mistakes that are cheap to find automatically.

How do you verify enforcement before deployment?

Use deterministic Guard responses and a handler call counter. A DENY or unavailable evaluation must leave the counter at zero; an authorized ALLOW must call the handler once. Also test another tenant's resource identifier: application authorization must still refuse access even when Guard allows.

Test both an authored tool wrapper and a filesystem or MCP tool reached through middleware. Exercise an approval interrupt and resume. Confirm the resumed call still evaluates policy, the denied handler stays uncalled, and the same action is not charged twice by overlapping wrappers.

What do you do after this guide?

Confirm each item against one sensitive flow: a prompt that asks for a refund, an authored lookup that includes a note, and a filesystem-injected tool that you didn't wrap. Then apply AI agent runtime security for sequence, budget, and identity controls that aren't Genkit-specific.

Frequently asked questions

What is the Genkit security guide?

To secure a Genkit application, screen inbound text before generation, wrap authored ToolAction handlers, and use middleware for unwrapped tools. Resolve identity from your server and authorize each requested resource before execution. Keep human approval, runtime policy, and tool-result capture separate so an observation cannot be mistaken for enforcement.

Does this guide cover Go or Python Genkit?

No. The Arcjet adapter is Genkit JS only. Don't wrap Go or Python Genkit with @arcjet/guard/genkit/v1.

Why omit outputSchema on a guarded tool?

outputSchema validation runs inside action(). Wrapping the inner handler would throw on a schema-mismatched denial and fail generate(). guardTool wraps the returned ToolAction instead.

Can a raw generate middleware function deny a tool?

No. A raw function becomes a model hook only. Pass guardMiddleware as a { name, instantiate } object on generate({ use }).

AI runtime security in your code

Protect your AI agent workflows with Arcjet

Arcjet guards run inside the tool, so the allow or deny arrives before the side effect rather than after it.