Managing secrets in Google ADK agents
Frameworks
An Agent Development Kit project is a directory with three files:
my_agent/
__init__.py
agent.py # defines root_agent
.env # GOOGLE_API_KEY, or the project and location for Gemini Enterprise
adk web, adk run my_agent, and adk api_server load the .env from the
agent's directory, and the Gemini client reads GOOGLE_API_KEY from the
environment. It is a clean layout for a quickstart. It is also a plaintext
credentials file that grows one line per tool: the weather API key, the
Stripe key for the billing tool, the database URL for the lookup tool, all
beside the model key, all readable by every tool and by the ADK web UI's
process.
Where ADK puts credentials without telling you
Three places, beyond the .env.
Sessions. ADK persists session state and every event in a run through a
session service. With DatabaseSessionService or the managed one, tool call
arguments and tool results are stored. A tool that returns a response body
containing an authorization header, or one that echoes a credential into
state, writes it to the session store, where it survives the run.
The dev UI. adk web shows events, state, and tool traces. It is explicitly
not for production, and anything in the agent's environment is a printenv
tool call away from being displayed in it.
Cloud Run. adk deploy cloud_run builds an image and deploys it. The keys
have to be in the service's environment, so they become Cloud Run environment
variables or Secret Manager references, one per key, set once per service per
environment.
Shape 1: wrap the process
For local development, nothing in the project changes:
seekrit run -- adk web
seekrit run -- adk run my_agent
seekrit run -- adk api_server
Every secret in the token's environment is injected into the child process.
Delete the .env. ADK still loads one if it exists, and a file that exists is
a file a coding agent reads.
If you are on the Gemini Enterprise path with Application Default Credentials,
there is no GOOGLE_API_KEY at all, and this shape is for the tool keys only.
Shape 2: resolve in code
Where there is no process to wrap, resolve at the top of agent.py:
import seekrit
from google.adk.agents import Agent
seekrit.Client().into_env() # before the agent is built
root_agent = Agent(
name="support",
model="gemini-3.1-flash",
instruction="Answer billing questions.",
tools=[lookup_charge],
)
into_env() leaves existing environment variables alone by default, so a
value set by the platform still wins. For a Cloud Run service this is the
whole integration: one environment variable, SEEKRIT_TOKEN, and the rest
resolved at cold start.
For the container itself, the static launcher does the same without a code
change. If you write the Dockerfile rather than letting adk deploy generate
one:
FROM seekritdev/run:latest AS seekrit
FROM python:3.13-slim
COPY --from=seekrit /seekrit-run /usr/local/bin/seekrit-run
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir google-adk
ENTRYPOINT ["seekrit-run", "--"]
CMD ["adk", "api_server", "--host", "0.0.0.0", "--port", "8080", "."]
gcloud run deploy support-agent --source . \
--set-secrets SEEKRIT_TOKEN=seekrit-token:latest
One Secret Manager entry per service instead of one per key, and adding a key
to the agent means adding it to the seekrit environment rather than
redeploying with a new --set-secrets flag.
Shape 3: tools hold placeholders
The Gemini key buys tokens. The tool keys do things. ADK tools are Python functions, so a tool's HTTP client is yours to configure, and the in-process transport fits there:
import httpx
from seekrit.transport import AllowRule, SeekritTransport
stripe = httpx.Client(
base_url="https://api.stripe.com",
headers={"Authorization": "Bearer {{seekrit:STRIPE_KEY}}"},
transport=SeekritTransport(
rules=[AllowRule.from_dict({
"host": "api.stripe.com",
"methods": ["GET"],
"paths": ["/v1/charges", "/v1/charges/*"],
"allow": ["STRIPE_KEY"],
})],
),
)
def lookup_charge(charge_id: str) -> dict:
"""Look up a Stripe charge by id."""
return stripe.get(f"/v1/charges/{charge_id}").json()
The function's closure, the session store, the dev UI, and os.environ hold
{{seekrit:STRIPE_KEY}}. The value exists inside one outbound request. A
refund is a POST to /v1/refunds, so the rule refuses it before it leaves
the process, and the refusal names the constraint rather than failing with a
generic connection error.
For tools that need a real value, like a database connection, keep them on shape 2 and bind the agent's token to an environment holding only what its tools need. For a database specifically, a temporary-access lease mints a credential per run that expires after it.
The Gemini key itself stays on shape 1 or 2. The google-genai client
supports a custom base URL only on the Enterprise path, so routing the Gemini
API key through a proxy is not an option today. It is also the least
interesting key to protect: a leaked one spends your quota, while a leaked
tool key acts on your customers.
Sessions
Whichever shape you use, the session store records what tools return. Two habits keep credentials out of it:
- Return the fields the model needs, not the upstream response object.
{"amount": charge["amount"], "status": charge["status"]}, not the rawhttpx.Responseand its headers. - Never put a credential in
session.stateto pass it between tools. State is persisted and, with the web UI, displayed.
With placeholders in shape 3 the second habit becomes unnecessary: the thing
in state would be {{seekrit:STRIPE_KEY}}.
Picking a shape
| Use | |
|---|---|
Local development with adk web | 1 |
| Cloud Run, or any deployment | 2, with seekrit-run as the entrypoint or into_env() at import |
| Any tool that spends money or changes state | 3 |
| A tool that needs a database | 2 on a narrow environment, or a lease |
Setup is three commands: import the .env into an environment, mint a token
bound to it, run the agent through the CLI. The values are encrypted on your
machine before upload, and the API that serves them to the agent
cannot read them.