Skip to content

Secret injection: worked examples

A cookbook of request_transforms.secrets swaps for real APIs — mainly LLM providers, since that’s what most agents spend their egress on, plus a handful of others that come up constantly. The YAML examples are profile fragments: place them inside a full profile or transform bundle, and replace provider-specific values before use. See Transforms for what source, inject and rules mean and the full list of injection kinds; this page is “what shape does provider X want”, not the mechanism.

The concrete YAML fragments are checked within a base config containing profile:. Validation checks marshal’s schema, not provider permissions or credential validity. None of them is a recommendation to trust these hosts with more than the policy chain in front of them grants — rules is the entire trust boundary for a swap (see ADR-0027), so scope it as narrowly as the endpoint that actually needs the credential, not as broadly as “the provider’s whole domain” out of convenience.

Every { type: env, var: X } below needs X set somewhere. The environment is one place; the env file — .env next to the config — is the other, and is usually the easier one for a machine that isn’t running marshal under systemd.

Most APIs fall into one of five buckets. If a provider isn’t listed below, this is usually enough to work it out from their docs:

what their docs sayuse
Authorization: Bearer <token>inject: { type: bearer }
Authorization: Basic <user:pass base64>inject: { type: basic, username: "..." }
a custom header (X-Api-Key, api-key, X-Auth-Token, …)inject: { type: header, name: "..." }
?api_key=... / ?key=... in the URLinject: { type: query, name: "..." }
“get a short-lived token from our OAuth endpoint first”source: { type: oauth2, ... } — see below and OAuth2 credentials
AWS SigV4 (Authorization: AWS4-HMAC-SHA256 ...)inject: { type: sigv4, ... } — see Transforms § AWS SigV4

A provider that documents both Bearer and Basic (Stripe is one) — prefer Bearer. It’s one secret instead of a fixed username plus a secret, and it’s usually the shorter path through their docs.


Bearer token, nothing unusual.

request_transforms:
secrets:
- name: OPENAI
source: { type: env, var: OPENAI_API_KEY }
inject: { type: bearer }
rules: [{ host: "api.openai.com" }]

If requests carry an OpenAI-Organization or OpenAI-Project header, those aren’t secrets — set them with set_headers alongside this swap, not through injection.

Both native decision services use a Bearer token. For agent requests, inject the origin’s credential after model routing, with a rule scoped to api.typesafe.ai or decisionapi.net as appropriate. The Decision APIs guide includes a complete routing and secret-injection example for both services.

Judge calls use the provider’s api_key_env directly rather than request transforms; see using a native decision judge. DecisionsApi is an independent service, so its key is separate from an OpenAI API key.

Same shape as OpenAI — OpenRouter’s API is intentionally OpenAI-compatible.

request_transforms:
secrets:
- name: OPENROUTER
source: { type: env, var: OPENROUTER_API_KEY }
inject: { type: bearer }
rules: [{ host: "openrouter.ai" }]

OpenRouter’s own docs recommend an HTTP-Referer and X-Title header so usage shows up correctly attributed on their dashboard — worth adding via set_headers if you use it, but check their current docs for the exact header names before relying on it; that’s attribution, not authentication, so it isn’t this page’s concern.

Anthropic uses a custom header, not Authorization — x-api-key — plus a required anthropic-version header that has nothing to do with the credential and belongs in set_headers.

request_transforms:
set_headers:
anthropic-version: "2023-06-01"
secrets:
- name: ANTHROPIC
source: { type: env, var: ANTHROPIC_API_KEY }
inject: { type: header, name: "x-api-key" }
rules: [{ host: "api.anthropic.com" }]

This is also what Claude Code uses when it’s configured for direct API billing (the ANTHROPIC_API_KEY path) rather than a Pro/Max subscription login — see below for the subscription case, which is a different credential shape entirely.

Gemini accepts API keys in the x-goog-api-key header; a query key is another authentication form. This example uses a header, avoiding credentials in URL query strings. See Google’s API-key guide.

request_transforms:
secrets:
- name: GEMINI
source: { type: env, var: GEMINI_API_KEY }
inject: { type: header, name: "x-goog-api-key" }
rules: [{ host: "generativelanguage.googleapis.com" }]

Azure’s own header, api-key (lowercase, no Authorization prefix at all), and the host is per-deployment rather than a single shared one — so a swap here is usually scoped to a single customer’s resource name rather than a wildcard.

request_transforms:
secrets:
- name: AZURE_OPENAI
source: { type: env, var: AZURE_OPENAI_API_KEY }
inject: { type: header, name: "api-key" }
rules: [{ host: "your-resource-name.openai.azure.com" }]

Google Vertex AI (service account, no static key at all)

Section titled “Google Vertex AI (service account, no static key at all)”

Vertex AI authenticates with a Google service account rather than a long-lived key — grant: jwt_bearer (RFC 7523) is built for exactly this, and it uses a private signing key instead of a static API token: marshal signs a fresh assertion and exchanges it for an access token on every mint. See OAuth2 credentials for the field reference.

request_transforms:
secrets:
- name: VERTEX
source:
type: oauth2
grant: jwt_bearer
token_endpoint: https://oauth2.googleapis.com/token
client_id: your-service-account@your-project.iam.gserviceaccount.com
client_auth: none
# The service-account JSON file Google Cloud IAM gives you — the PEM is one field
# inside it, and `json_key` reads straight into that field with no conversion step.
private_key: { type: file, path: /etc/bot-marshal/vertex-sa.json, json_key: private_key }
scope: ["https://www.googleapis.com/auth/cloud-platform"]
inject: { type: bearer }
rules: [{ host: "*.googleapis.com" }]

Scope rules down to aiplatform.googleapis.com if this credential shouldn’t also authenticate to every other Google API the service account happens to have IAM roles for.


Both of the following tools are HTTP clients like any other from marshal’s point of view — the interesting part is which credential they end up presenting, since both support more than one auth mode.

When ANTHROPIC_API_KEY is set in its environment, Claude Code talks straight to api.anthropic.com exactly as described in the Anthropic section above. Reuse that swap; there is nothing Claude-Code-specific about it.

Claude Code — subscription login (claude login)

Section titled “Claude Code — subscription login (claude login)”

Logging in with a Claude Pro/Max subscription instead of an API key is an interactive OAuth flow against Anthropic’s own auth infrastructure. The client_id and endpoints belong to their client application and are not published — so rather than asking you to find them, marshal can watch a real login and learn them:

Terminal window
marshal secrets oauth login CLAUDE_SUBSCRIPTION --wait

Bootstrap needs a base configuration, CA and state_dir; follow the complete enrolment workflow. Run the tool’s login in a separate terminal with the printed proxy/trust settings. The browser itself does not need proxying for bootstrap: marshal watches the tool’s token exchange.

For a tool that opens a browser and waits on loopback, use --wait or launch with --isolation cgroup, which identifies without network containment:

Terminal window
marshal secrets oauth login CLAUDE_SUBSCRIPTION --run --isolation cgroup -- claude login

After success, edit the generated transforms/CLAUDE_SUBSCRIPTION.yaml: replace the rules host with the API host and attach the bundle to the intended profile. It contains the token endpoint and client ID discovered from the exchange; bootstrap does not discover the authorization endpoint. An already-enrolled authorization-code source needs neither that endpoint nor a redirect URI to refresh. Keep the stored grant under the same state_dir and run config check on the machine holding it. Do not replace generated values with guessed provider URLs. In default observe mode the tool also keeps its own credential; use steal only after reading the capture tradeoff.

If instead you want Claude Code to drive its own login through the proxy every time and never hold anything, that is capture: in_band (OAuth2 credentials § In-band capture) — a different mechanism with a different threat model; read ADR-0034 on which applies.

Same story as Claude Code: with OPENAI_API_KEY set, Codex talks to api.openai.com exactly as in the OpenAI section above. Reuse that swap.

Same situation as Claude Code’s subscription login, and the same answer — the credentials belong to OpenAI’s own client application, so let marshal observe a real login rather than hunting for them:

Terminal window
marshal secrets oauth login CODEX_SUBSCRIPTION --wait
# browser/loopback flow: identify without netns isolation
marshal secrets oauth login CODEX_SUBSCRIPTION --run --isolation cgroup -- codex login

The canonical example from Transforms — git clone with no credential anywhere in the command:

request_transforms:
secrets:
- name: GITHUB_GIT
source: { type: env, var: GITHUB_TOKEN }
inject: { type: basic, username: "x-access-token" }
rules: [{ host: "github.com" }]

The API itself takes a plain Bearer token rather than the git-over-HTTPS Basic shape above — scope it to api.github.com specifically so it doesn’t leak onto plain github.com traffic (and vice versa):

request_transforms:
secrets:
- name: GITHUB_API
source: { type: env, var: GITHUB_TOKEN }
inject: { type: bearer }
rules: [{ host: "api.github.com" }]

Bearer token — a bot token (xoxb-...) works exactly like any other Bearer credential.

request_transforms:
secrets:
- name: SLACK
source: { type: env, var: SLACK_BOT_TOKEN }
inject: { type: bearer }
rules: [{ host: "slack.com" }]

Stripe’s docs lead with Basic auth (secret key as the username, blank password) but also accept plain Bearer — take Bearer, since it’s one secret instead of a fixed empty password sitting next to a real one.

request_transforms:
secrets:
- name: STRIPE
source: { type: env, var: STRIPE_SECRET_KEY }
inject: { type: bearer }
rules: [{ host: "api.stripe.com" }]
request_transforms:
secrets:
- name: NPM
source: { type: env, var: NPM_TOKEN }
inject: { type: bearer }
rules: [{ host: "registry.npmjs.org" }]

Fully documented in Transforms § AWS SigV4 — it needs an access key pair rather than one secret, and it’s the one kind that buffers the request body to hash it. Not repeated here; that section is the reference.

A generic internal API (OAuth2 client credentials)

Section titled “A generic internal API (OAuth2 client credentials)”

The machine-to-machine case that most non-LLM SaaS and internal-platform APIs actually use — Auth0, Okta, and most homegrown auth servers all speak this shape:

request_transforms:
secrets:
- name: INTERNAL_API
source:
type: oauth2
token_endpoint: https://your-tenant.auth0.com/oauth/token
client_id: your-m2m-client-id
client_secret: { type: env, var: INTERNAL_API_CLIENT_SECRET }
# Most client_credentials providers want the target API identified this way rather
# than (or as well as) scope — check whether yours calls this `audience`, `resource`,
# or something else, and use `extra_params` if it's neither.
audience: "https://your-internal-api.example.com"
inject: { type: bearer }
rules: [{ host: "your-internal-api.example.com" }]

grant: client_credentials is the default, so it doesn’t need to be spelled out. See OAuth2 credentials for refresh_token, jwt_bearer, and the interactive grants this same source type also supports.