seekrit
← all posts

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 raw httpx.Response and its headers.
  • Never put a credential in session.state to 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 web1
Cloud Run, or any deployment2, with seekrit-run as the entrypoint or into_env() at import
Any tool that spends money or changes state3
A tool that needs a database2 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.