How do you secure Google ADK?
To secure Google ADK, screen text before the runner starts and register a before-tool callback that can skip the tool. JavaScript uses guardPlugin on the Runner. Python uses guard_plugin on the Runner, or guard_tool on the agent when you hold the agent instead. Authorize resources inside the tool service and use a server-resolved identity for the actor and for per-user limits. Consume the runner's event stream when testing; creating an iterator alone does not execute a run.
Use this guide on Google Agent Development Kit (ADK) 2.x: TypeScript @google/adk and Python google-adk.
ADK builds an LlmAgent from a model, an instruction, and a list of tools. The runner drives the loop: the model reads user text, decides to call a FunctionTool, and ADK executes that tool. Prompt injection is when untrusted text – a pasted ticket, a retrieved document, a tool result from an earlier turn – steers that decision toward a side effect you never intended.
Arcjet Guard is a runtime policy layer for AI agents. You define rules – prompt-injection detectors, rate limits, PII checks, allowlists – and a published remote policy, and Guard allows or denies inbound text and tool calls before side effects run. JavaScript examples use @arcjet/guard/google-adk/v2. Python examples use arcjet.guard.google_adk from arcjet[google-adk]. Pick one language. Don't mix the two adapters on one agent.
This page is Google ADK, not Google GenAI (@google/genai). HTTP request protection on a route never sees runAsync or run_async, so a WAF in front of the app doesn't cover any of this.
| Runtime | Packages | Guard package | Pre-exec gate |
|---|---|---|---|
| JavaScript | @google/adk>=2 <3 | @arcjet/guard/google-adk/v2 |
|
| Python | google-adk>=2.0.0,<3 |
|
|
Use this checklist to review your application:
- Patch and lock dependencies. Stay on ADK 2.x and import the versioned JavaScript path.
- Keep model keys off the client and out of session state.
- Screen user text with a direct
guard()call before the runner. - Don't treat confirmation, or ADK
SecurityPlugin, as a policy gate. - Gate every tool call before it runs. Put the Runner plugin first in
plugins. - Deny by returning the deny dict. Never throw or raise. Never return
undefined,None, or{}on error. - Pass
actorand typedinputsfrom server state when a remote policy needs them. Never from a model-produced argument. - Correlate on an ID you own. Never on an ADK-generated one.
- Catch issues in the editor and in CI before they ship.
For the Guard helpers used in the examples, see the Google ADK agent guard.
How do Google ADK's built-in controls compare with Arcjet Guard?
ADK can pause for a person and it can run its own SecurityPlugin. Neither is a remote policy that denies a tool before execute runs.
| Control | What ADK does | What Arcjet Guard adds |
|---|---|---|
| Human-in-the-loop. The run pauses and a person is asked. | Not a Guard decision. There is no approval helper. Policy still has to run on the before-tool callback. |
SecurityPlugin | ADK's own plugin. It is not the Arcjet gate. | Don't wire Arcjet into it. The gate is |
| The first truthy dictionary any plugin returns skips the tool. An empty Python mapping is falsy, so the tool still runs. | Guard returns a deny dict before the handler runs, or
|
| Tool arguments and session ids ADK generates | The model produced the arguments. ADK can mint a session id. |
|
The Runner plugin fails closed. If Guard cannot be evaluated, the callback returns the deny dict with reason: "ERROR" instead of calling the tool. A direct guard() call still fails open, which is why inbound screening also checks hasFailedOpen() / has_failed_open().
How do you keep Google ADK 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 in CI and fail on known high-severity issues you haven't waived.
@google/adk is an optional peer of @arcjet/guard (>=2 <3), not a dependency of it. If ADK is already in your project at that range, install @arcjet/guard on its own so your existing pins don't move. The JavaScript integration requires Node.js 22.21 or later, or Node.js 24.5 or later. It does not support the 23.x line.
npm install @arcjet/guard @google/adkImport from the versioned path. The version segment is the ADK major, and there is no unversioned alias – @arcjet/guard/google-adk does not resolve.
import { guardPlugin, googleAdkContext } from "@arcjet/guard/google-adk/v2";Python installs the extra, which depends on google-adk>=2.0.0,<3. CPython 3.10 or later.
pip install "arcjet[google-adk]"from arcjet.guard.google_adk import google_adk_context, guard_plugin, guard_toolPin the ADK package so a 3.x major can't land under that import. An ADK major is the thing most likely to move the tool-call callback contract, and the JavaScript adapter path is versioned precisely so the mismatch is a resolution error rather than a silently skipped gate. See trivial packages and dependency confusion.
How do you manage Google ADK secrets?
Model credentials – GOOGLE_API_KEY, a Vertex AI service account, ARCJET_KEY – belong on the server, loaded at process start. Launch one Arcjet client at module scope and reuse it, rather than constructing one per request. Python sync servers (Flask, Django) use launch_arcjet_sync instead of launch_arcjet.
import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });Don't put credentials in ADK session state. Session state is read back into model context on later turns, which makes it reachable by anything that can influence the transcript. It is for caller-owned identifiers – a conversation ID, a tenant ID – not secrets.
Don't log raw tool arguments or the raw prompt. Tool arguments are model-generated from user text, so they carry whatever the user put in. See storing secrets in environment variables and redacting sensitive data from logs.
How do you screen inbound Google ADK text?
Neither adapter exports an inbound helper. Screen in application code before runner.runAsync or runner.run_async, while the user's text is still a string you control and not yet part of the agent's turn history. The Python call is in How do you secure Google ADK in Python?.
Direct guard() fails open: if Guard can't be evaluated, the call returns ALLOW. That means an ALLOW on its own is not proof the rules ran. Gate on decision.hasFailedOpen() when this call site must fail closed. On a deny, don't call runAsync at all.
import { detectPromptInjection } from "@arcjet/guard";import { googleAdkContext } from "@arcjet/guard/google-adk/v2";import { arcjet } from "./arcjet.js";
const inbound = detectPromptInjection();const appContext = { sessionId: conversationId };
const decision = await arcjet.guard({ label: "message.received", rules: [inbound(userText)], ...googleAdkContext(appContext),});
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) { throw new Error("message blocked");}Note the field name. A direct guard() call takes label. A framework wrapper such as guardPlugin takes action for the same slug. Passing label to a wrapper is a type error, and passing action to guard() won't name the event.
A clean inbound score does not authorize a tool call. Inbound screening decides whether the model sees the prompt; it says nothing about whether the refund tool may run. That is the second control, and it is separate. See prompt injection detection.
Why isn't requestConfirmation a Google ADK security policy?
ADK can pause a run and wait for a person. requireConfirmation on a tool and requestConfirmation in a handler are human-in-the-loop confirmation. Python spells the same controls require_confirmation and request_confirmation. They're useful when someone must review context, and they are not a policy gate: they ask, they don't decide, and nothing evaluates a rule.
This is the same trap under different names across frameworks – LangChain humanInTheLoopMiddleware, Strands event.interrupt(), Genkit interrupt(), OpenAI needsApproval, Mastra requireApproval, TanStack needsApproval, Cloudflare Think needsApproval, Claude canUseTool. There is no approval helper on either ADK adapter.
ADK's own SecurityPlugin is likewise not the Arcjet policy gate. Don't wire Arcjet into it, and don't express a denial by pausing for a human. Policy sits on the before-tool callback only. See human approval is not a security policy and needsApproval and interrupt() are not a policy.
How do you gate Google ADK tool calls?
JavaScript has no guardTool. Wrapping FunctionTool.execute is the wrong gate, because ADK's skip point is the Runner callback, not the handler. The gate is guardPlugin, a BasePlugin you pass on new Runner({ plugins }) or new InMemoryRunner({ plugins }). Python's guard_tool is a before_tool_callback, not a handler wrap. That section is below.
Its beforeToolCallback returns one of two things. A deny dict – an ArcjetDenialResult – skips the tool, and execute never runs. undefined executes the tool. It never throws.
Put it first in the plugin list. beforeToolCallback uses the first dictionary any plugin returns. If another plugin returns a dict ahead of Arcjet, Guard never runs and the gate is silently gone.
import { localDetectSensitiveInfo, tokenBucket } from "@arcjet/guard";import { guardPlugin } from "@arcjet/guard/google-adk/v2";import { FunctionTool, InMemoryRunner, LlmAgent } from "@google/adk";import { z } from "zod";import { arcjet } from "./arcjet.js";
// 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 lookupOrderInput = z.object({ orderNumber: z.string(), note: z.string(),});
const lookupOrder = new FunctionTool({ name: "lookup_order", description: "Look up an order by number", parameters: lookupOrderInput, execute: ({ orderNumber, note }) => ({ orderNumber, note, status: "shipped", }),});
const agent = new LlmAgent({ name: "order_agent", model: "gemini-flash-latest", instruction: "Look up orders with lookup_order.", tools: [lookupOrder],});
const runner = new InMemoryRunner({ agent, appName: "orders", plugins: [ guardPlugin(arcjet, { action: "order.looked-up", sessionId: conversationId, onGuardError: "deny", // Server session, resolved before the run. Never a tool argument. actor: authenticatedUser.id, rules: ({ toolName, input }) => { if (toolName !== "lookup_order") { return []; } const { orderNumber, note } = lookupOrderInput.parse(input); return [ lookupLimit({ key: authenticatedUser.id, requested: 1 }), detectPii(note), ]; }, }), ],});guardPlugin is Runner-wide, so rules receives { toolName, input } for every tool the agent calls. Branch on toolName and return [] for the tools you aren't scoring. The guard call still happens, which is what puts the decision in the Console; returning [] only means you submitted no SDK rules for it.
Re-parse input with the tool's own schema rather than trusting its shape. The model produced those arguments, so they are untrusted input in the same sense the prompt was.
Key a per-user rate limit on authenticatedUser.id, resolved by your server before the run. The model supplies orderNumber, so it is a resource selector, not a trusted identity; changing it must not reset the caller's budget. Authorize access to the selected order in the tool service.
Scan the free-text fields – a note, a reason, a body. An opaque orderNumber or tool-call ID won't trip email, phone, card, or IP detection, so passing it to localDetectSensitiveInfo adds cost and no coverage. That helper runs on a local model backend, so the text never leaves your process. See detecting and redacting PII in LLM inputs and outputs.
What does a Google ADK denial look like?
On DENY the original tool never runs and the model receives an ArcjetDenialResult. This is a returned value, not a thrown error – a throw is the wrong envelope, and ADK will surface it as a crash instead of a result the model can read.
import type { ArcjetDenialResult } from "@arcjet/guard/google-adk/v2";
const denial: ArcjetDenialResult = { arcjetDenied: true, reason: "RATE_LIMIT", // or PROMPT_INJECTION, SENSITIVE_INFO, ERROR message: "Arcjet denied this call (RATE_LIMIT). It may be retried after 30 seconds.", retryable: true, retryAfterSeconds: 30,};Rate-limit policy denials can set retryable: true with retryAfterSeconds. Prompt-injection and sensitive-information denials must not be retried. An unavailable evaluation is a separate case: the helper returns reason: "ERROR" with a five-second retry hint. Bound retries in application code and never retry by bypassing the guard.
guardPlugin defaults to onGuardError: "deny". When Guard can't be evaluated, the callback returns the deny dict with reason: "ERROR", retryable: true, and retryAfterSeconds: 5. It never throws on that path, and it never returns undefined – returning undefined on an error would execute the tool without a decision, which is the failure mode the default exists to prevent.
Set onGuardError: "allow" only where executing without a complete security decision is genuinely acceptable, such as a lookup whose data is safe to disclose even without a Guard decision. Remember that direct guard() still fails open regardless, which is why the inbound example gates on hasFailedOpen().
Python guard_tool and guard_plugin use the same deny dict. Return None to execute. Never return {}: an empty mapping is falsy in ADK's callback chain, so the tool would run. They default to on_guard_error="deny", never raise, and never return None on that error path.
How do you secure Google ADK in Python?
The Python adapter is arcjet.guard.google_adk. It is not @arcjet/guard/google-adk/v2. Don't pass guardPlugin from the JavaScript package to a Python Runner, and don't pass guard_plugin the other way.
guard_plugin is the Runner-wide gate. Pass it first on InMemoryRunner or Runner plugins. action defaults to {tool_name}.invoked. guard_tool assigns LlmAgent.before_tool_callback. It is not a FunctionTool wrap, and action is required. Plugins run first. Attaching both to the same tools calls Guard twice. If you must stack, list the guard_tool names in exclude on guard_plugin only.
The rules callable receives the tool-call envelope: model-produced arguments plus tool_name. Read call["order_number"], not call["input"]. Key a limit on the authenticated user id you resolved before the run. order_number selects a resource. It is not an identity, and it is not an actor.
import os
from google.adk.agents import LlmAgentfrom google.adk.runners import InMemoryRunnerfrom google.adk.tools import FunctionToolfrom google.genai import typesfrom arcjet.guard import ( DetectPromptInjection, LocalDetectSensitiveInfo, TokenBucket, launch_arcjet,)from arcjet.guard.google_adk import google_adk_context, guard_plugin
arcjet = launch_arcjet(key=os.environ["ARCJET_KEY"])
lookup_limit = TokenBucket( refill_rate=10, interval_seconds=60, max_tokens=10, bucket="lookups",)detect_pii = LocalDetectSensitiveInfo()inbound = DetectPromptInjection()
def lookup_order(order_number: str, note: str) -> dict: """Look up an order by number.""" return {"order_number": order_number, "note": note, "status": "shipped"}
async def run_agent(conversation_id: str, user_text: str, authenticated_user_id: str): app_context = {"session_id": conversation_id} derived = google_adk_context(app_context) decision = await arcjet.guard( label="message.received", rules=[inbound(user_text)], correlation_id=derived.correlation_id, ) if decision.conclusion == "DENY" or decision.has_failed_open(): raise RuntimeError("message blocked")
def plugin_rules(call): if call["tool_name"] != "lookup_order": return [] return [ lookup_limit(key=authenticated_user_id, requested=1), detect_pii(call["note"]), ]
agent = LlmAgent( name="order_agent", model="gemini-flash-latest", instruction="Look up orders with lookup_order.", tools=[FunctionTool(lookup_order)], ) runner = InMemoryRunner( agent=agent, app_name="orders", plugins=[ guard_plugin( guard=arcjet, action="order.looked-up", session_id=conversation_id, on_guard_error="deny", actor=authenticated_user_id, rules=plugin_rules, ) ], ) return runner.run_async( user_id=authenticated_user_id, session_id=conversation_id, new_message=types.Content( role="user", parts=[types.Part.from_text(text=user_text)], ), )When you hold the agent and not the Runner, assign guard_tool on before_tool_callback and do not also register guard_plugin for those tools.
from arcjet.guard.google_adk import guard_tool
agent = LlmAgent( name="order_agent", model="gemini-flash-latest", instruction="Look up orders with lookup_order.", tools=[FunctionTool(lookup_order)], before_tool_callback=guard_tool( guard=arcjet, action="order.looked-up", session_id=conversation_id, actor=authenticated_user_id, rules=lambda call: ( [lookup_limit(key=authenticated_user_id, requested=1)] if call["tool_name"] == "lookup_order" else [] ), ),)google_adk_context reads correlation_id, then session_id, then conversation_id. Camel-case aliases are also read. It never mints an id, and it never reads trace_id, an ADK-generated invocation_id, or session.id.
How do remote policies read the actor and inputs?
actor and inputs are optional on guardPlugin, guard_tool, and guard_plugin. Omit them and a remote rule that declares those names never fires. A resolver that throws is a guard error and follows the fail-closed default.
Derive actor from authenticated application state. A policy can be conditioned on it, so a model that controls the actor can leave its own policy scope. Tool arguments are model-produced. Narrow them before you read a field, and return nothing for a tool the policy does not cover.
JavaScript builds each input with policyInput.server.* or policyInput.local.*. Plain values are rejected. The adapter does not discover arguments for you.
import { policyInput } from "@arcjet/guard";import { guardPlugin } from "@arcjet/guard/google-adk/v2";import { arcjet } from "./arcjet.js";
const guarded = guardPlugin(arcjet, { action: ({ toolName }) => `${toolName}.invoked`, actor: authenticatedUser.id, inputs: ({ toolName, input }) => { if (toolName !== "sendEmail") return {}; const { recipient, body } = input as { recipient: string; body: string }; return { recipient: policyInput.server.string(recipient), allowed_recipients: policyInput.server.stringList( authenticatedUser.allowedRecipients, ), body: policyInput.local.string(body), }; },});Python builds each input with server_input.* or local_input.*. The envelope is the arguments plus tool_name.
from arcjet.guard import local_input, server_inputfrom arcjet.guard.google_adk import guard_tool
guarded_send_email = guard_tool( guard=arcjet, action="email.sent", actor=authenticated_user_id, inputs=lambda call: { "recipient": server_input.string(call["recipient"]), "allowed_recipients": server_input.string_list(allowed_recipients), "body": local_input.string(call["body"]), },)The policy these calls feed declares recipient as a SERVER string, allowed_recipients as a SERVER string list, and body as a LOCAL string, then denies a recipient that isn't on the list:
package arcjet.guard
import rego.v1
deny contains "external-recipient" if { not input.values.recipient in input.values.allowed_recipients}allowed_recipients is application state, not a field the model supplied. See remote policies for the contract.
How do you correlate a Google ADK run?
googleAdkContext reads a caller-owned ID from the object you pass: correlationId first, then sessionId, then conversationId. It never mints an ID.
It deliberately ignores every ADK-generated value – traceId, an ADK-generated invocationId, toolContext.sessionId, and session.id. ADK will generate a session or invocation ID when you omit one, and joining decisions on an auto-generated value produces a Sequence that looks correlated but groups unrelated runs. If you didn't pass an ID, the call is uncorrelated, which is the honest outcome. Don't derive one from a generated value to fill the gap.
Put the same ID on runAsync and on guardPlugin, so the inbound decision and every tool decision land on one Sequence. Python puts that ID on run_async(session_id=...) and as session_id on guard_tool or guard_plugin. google_adk_context reads correlation_id, then session_id, then conversation_id, plus the camel-case aliases.
// After the inbound check, use a session created for this authenticated user.for await (const event of runner.runAsync({ userId: authenticatedUser.id, sessionId: conversationId, newMessage: { parts: [{ text: userText }] },})) { void event; // Forward permitted events to the caller.}A run that pauses on requestConfirmation resumes through a later runAsync. Pass the same caller-owned ID on that resume call, or the second half of the conversation detaches from the first. The confirmation payload itself is not a correlation source.
Don't call createAgentContext inside an ADK callback.
How do the editor and CI catch Google ADK 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 gets ignored.
Fail the build on secrets in the diff and on known vulnerable dependencies. TypeScript catches the adapter mistakes that matter most here: label passed to guardPlugin, an unversioned @arcjet/guard/google-adk import, or a rules callback that destructures input without parsing it. Python review should reject a git URL pin, a guard_tool stacked on the same tools as guard_plugin, and a deny path that returns {}.
What automation won't catch is plugin ordering. Nothing type-checks that guardPlugin or guard_plugin sits first in plugins, and a Guard that never runs looks exactly like a Guard that always allows. Make that a review item, and verify it against a real denial rather than inferring it from the absence of a side effect.
How do you verify the gate actually fires?
A missing decision is not a denial. If the model asks a clarifying question instead of calling the guarded tool, nothing is sent, no guard call happens, and no decision is returned. From the outside that is indistinguishable from a working gate, because in both cases the side effect didn't happen.
Read the decision in the Arcjet Console or your logs rather than concluding from the absence of a side effect. Two things make a test agent cooperative: a system prompt telling it to complete the request without follow-up questions, and one telling it to quote retrieved values verbatim. A model that helpfully masks a card number itself leaves the rule nothing to detect, and Guard then correctly allows – which reads as a broken rule when it isn't.
Check the three failure modes explicitly. Confirm a denial when a rate limit is exhausted, confirm a denial when a free-text argument carries PII, and confirm the tool doesn't run when Guard is unreachable and onGuardError is "deny". See functional testing of security rules.
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.
Use a fake model that requests a known tool so the test actually exercises beforeToolCallback. Consume the runAsync event iterator. Repeat with another plugin before and after Guard, and assert both the decision and handler call count; plugin ordering must not turn a blocked call into an executed one.
What do you do after this guide?
Confirm each item against one sensitive run: a prompt that asks for a refund, an authored lookup that includes a free-text note, and a second plugin in the list to prove ordering behaves as you expect.
Then apply AI agent runtime security for the sequence, budget, and identity controls that aren't ADK-specific, enforce token spend budgets for the cost side of an agent loop that can call tools repeatedly, and the Cloudflare Think security guide if the agent runs on Think rather than ADK.
Frequently asked questions
What is the Google ADK security guide?
To secure Google ADK, screen text before the runner starts and skip the tool from a before-tool callback. JavaScript registers guardPlugin first on the Runner. Python registers guard_plugin first, or guard_tool on the agent, not both. Authorize resources in the tool service and use a server-resolved actor. Consume the runner event stream when testing.
Is there a guardTool for Google ADK?
JavaScript gates tools with guardPlugin on beforeToolCallback and skips the tool by returning a deny dict. Wrapping FunctionTool.execute does not skip it. Python assigns guard_tool on LlmAgent.before_tool_callback, which is the callback and not a handler wrap. Do not attach guard_tool and guard_plugin to the same tools.
Why must guardPlugin be first in the plugins list?
beforeToolCallback uses the first dictionary any plugin returns. If another plugin returns one ahead of Arcjet, Guard never runs and the gate is silently gone.
Does this cover Python ADK and Google GenAI?
Python ADK is covered by arcjet[google-adk] (arcjet.guard.google_adk) on the same docs page as the JavaScript adapter. It is not Google GenAI (@google/genai). Don't wrap these tools with the Vercel AI SDK or Genkit adapters, and don't mix the JavaScript and Python ADK adapters.
Can a tool argument be the actor?
No. actor comes from authenticated application state. Tool arguments are model-produced, so a policy conditioned on an actor the model set can be left by the model. Build inputs with policyInput in JavaScript or server_input and local_input in Python.
Can I correlate on the ADK invocation id?
No. googleAdkContext reads correlationId, then sessionId, then conversationId from the object you pass. It ignores traceId, an ADK-generated invocationId, toolContext.sessionId, and session.id. Without a caller-owned ID the call is uncorrelated.
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.