OAuth2 credentials
There are four different ways marshal ends up holding an OAuth2 credential, and which one
applies depends on two questions: do you control the OAuth application (its client_id
and endpoints), and who is driving the login, marshal or the agent/tool.
| you control the OAuth application | you don’t (a vendor’s own client) | |
|---|---|---|
| no interactive login needed | a { type: oauth2 } source with grant: client_credentials, refresh_token, or jwt_bearer — authenticates from config alone, below | — |
| a human logs in once, marshal drives it | grant: authorization_code/device_code + marshal secrets oauth login <name>, below | § Bootstrap capture — marshal discovers the application from the exchange itself; no OAuth source declaration needed |
| an agent drives the login unattended | source.capture: in_band, § In-band capture — marshal takes the flow over so the agent gets nothing | not possible — capture needs the authorization endpoint declared in advance |
The configured-grant and marshal-driven enrolment cases use a { type: oauth2 } secret
source declared in a profile, and are what
the rest of this page covers up to and including § In-band capture.
§ Bootstrap capture is the separate CLI workflow: a CLI command with no source
declaration at all.
Every other source hands back a credential somebody else obtained. oauth2 obtains one:
marshal calls a token endpoint, caches the access token for its stated lifetime, and mints a
new one when it expires. The injected access token stays at marshal’s boundary. Protect the underlying client
credential or refresh token from the agent as you would any other secret source.
request_transforms: secrets: - name: SERVICE source: type: oauth2 token_endpoint: https://auth.example.com/oauth2/token client_id: marshal client_secret: { type: env, var: SERVICE_CLIENT_SECRET } scope: ["read:things"] inject: { type: bearer } rules: [{ host: "api.example.com" }]It is a source, not an injection kind, so it composes with all five: bearer is what
almost every API wants, but a service expecting its token on X-Api-Key works too, with no
special case. See ADR-0030.
name is required for an oauth2 source. It keys the token store and the redaction label, so
marshal needs it before the credential exists — there is nothing to derive it from.
| field | |
|---|---|
token_endpoint | the full URL, path included |
client_id | |
grant | client_credentials (default), refresh_token, jwt_bearer, authorization_code, device_code |
client_auth | client_secret_basic (default), client_secret_post, private_key_jwt, none |
client_secret | itself a source — { type: env, ... } or { type: file, ... }. Required for client_secret_basic and client_secret_post; not used by none or private_key_jwt |
refresh_token | a source. Required by grant: refresh_token; meaningless for the others |
scope | a list, joined with spaces per RFC 6749 |
audience | sent as audience= when set |
extra_params | name/value pairs sent verbatim on every token request — the escape hatch for a provider this does not otherwise model (resource, a tenant id, a vendor flag) |
expiry_skew | subtracted from the stated lifetime so a token cannot expire in flight. Defaults to 60s |
timeout | how long any single call to the provider may take. Defaults to 10s |
Grants
Section titled “Grants”client_credentials is machine-to-machine and needs nothing but the client credential. It
needs no state or enrolment; refresh_token and jwt_bearer can also operate without
marshal-owned persistent state.
refresh_token presents a long-lived refresh token that something outside marshal manages:
source: type: oauth2 grant: refresh_token token_endpoint: https://auth.example.com/oauth2/token client_id: marshal client_secret: { type: env, var: SERVICE_CLIENT_SECRET } refresh_token: { type: file, path: /etc/bot-marshal/service-refresh-token }If the provider rotates refresh tokens this grant cannot keep up: marshal does not own that file or environment variable and will not rewrite it, so it logs a warning and the configured value goes stale. Use an interactive grant against a rotating provider.
jwt_bearer (RFC 7523 §2.1) signs
an assertion with a private key and exchanges it — how a Google service account, Salesforce, or
Snowflake authenticates a workload with a key rather than a password. Nothing to enrol and
nothing to refresh: every mint signs a fresh assertion.
source: type: oauth2 grant: jwt_bearer token_endpoint: https://oauth2.googleapis.com/token client_id: svc@project.iam.gserviceaccount.com client_auth: none private_key: { type: file, path: /etc/bot-marshal/sa.json, json_key: private_key } scope: ["https://www.googleapis.com/auth/cloud-platform"]private_key is itself a source, so a Google service-account JSON file — which is JSON with
the PEM inside it — is read by the existing file source with json_key: private_key and no
special case. PKCS#8 and the older PKCS#1/SEC1 PEM forms are all accepted.
| field | |
|---|---|
private_key | a source yielding a PEM. Required by jwt_bearer and private_key_jwt |
algorithm | RS256 (default) or ES256. HS256 is deliberately absent — it is a shared secret wearing asymmetric clothes, so it offers nothing over client_secret_basic |
key_id | the assertion’s kid header, for a provider publishing more than one key |
issuer | the assertion’s iss. Defaults to client_id |
subject | the assertion’s sub. Defaults to issuer; set it to an impersonated user for Google’s domain-wide delegation |
assertion_audience | the assertion’s aud. Defaults to token_endpoint |
assertion_lifetime | defaults to 5m — an assertion is used once, immediately |
scope is sent both in the assertion and in the form body. RFC 7523 permits it in the form;
Google reads it only from the assertion; sending both is the union of what providers accept.
client_auth: private_key_jwt (RFC 7523 §2.2)
is the same signing machinery used for client authentication instead. It composes with any
grant, uses the same private_key/algorithm/key_id fields, and means there is no client
secret to rotate or to leak:
source: type: oauth2 token_endpoint: https://auth.example.com/oauth2/token client_id: marshal client_auth: private_key_jwt private_key: { type: file, path: /etc/bot-marshal/client.pem } algorithm: ES256 key_id: "2026-09"Each client assertion carries a fresh jti, which is what lets a provider reject a replayed one.
authorization_code and device_code are enrolled once by a human and then run
unattended. Both require state_dir, because the refresh token they
produce is the only copy and has to survive a restart. Until a swap is enrolled its requests
are refused with a message saying to run
marshal secrets oauth login,
rather than failing obscurely.
source: type: oauth2 grant: authorization_code token_endpoint: https://auth.example.com/oauth2/token authorization_endpoint: https://auth.example.com/oauth2/authorize redirect_uri: http://127.0.0.1:7777/callback # loopback only — marshal binds it client_id: marshal client_secret: { type: env, var: SERVICE_CLIENT_SECRET } scope: ["offline_access", "read:things"] source: type: oauth2 grant: device_code token_endpoint: https://auth.example.com/oauth2/token device_authorization_endpoint: https://auth.example.com/oauth2/device client_id: marshal client_auth: none scope: ["offline_access"]| field | |
|---|---|
authorization_endpoint | required by authorization_code, unless the swap is already enrolled — see below |
redirect_uri | required by authorization_code, unless already enrolled. Loopback only — marshal secrets oauth login binds it to receive the code, and a redirect anywhere else would deliver the code to something that is not marshal |
device_authorization_endpoint | required by device_code |
Both endpoint fields exist only to let marshal secrets oauth login <name> know where to
send the browser and where to receive it back — once a swap is enrolled, minting a fresh
access token only ever needs token_endpoint, client_id, and the stored refresh token,
never the authorization endpoint. So a swap that is already enrolled when config check
first sees it does not need either field. This is exactly the shape
bootstrap capture writes: it enrols the refresh token itself, from a
token exchange it observed, and never learns the authorization endpoint at all, since it only
ever watches the redemption, not the authorization request that came before it. Add either
field later and it takes effect the ordinary way; nothing about omitting it is permanent.
scope almost certainly needs offline_access (or Google’s access_type: offline in
extra_params): without it most providers complete the flow and issue no refresh token, and
marshal refuses to record an enrolment that would not survive a restart.
device_code is the one that works over SSH — it binds nothing and needs no browser on the
host.
In-band capture
Section titled “In-band capture”Everything above assumes marshal was given, or enrolled, the credential in advance. capture: in_band covers the other case: an agent that drives an OAuth authorization flow itself.
Left alone, that ends with the agent holding live tokens — precisely the state boundary
injection exists to prevent. Under capture: in_band it ends with the agent holding nothing:
source: type: oauth2 grant: authorization_code capture: in_band token_endpoint: https://auth.example.com/oauth2/token authorization_endpoint: https://auth.example.com/oauth2/authorize client_id: marshal client_secret: { type: env, var: SERVICE_CLIENT_SECRET } scope: ["offline_access"]What happens, in order:
- The agent’s authorization request has its PKCE challenge replaced with one marshal
derived from a verifier only marshal holds. Its
stateandredirect_uriare untouched, so the agent’s own CSRF check still passes and the provider still recognises the redirect URI. From here, the code the provider will issue is redeemable only by marshal — not because the agent is blocked from trying, but because it does not hold the matching verifier. - The redirect never reaches the agent with a real code in it. Marshal intercepts the
Locationheader, lifts the code out, and completes the exchange itself — a direct call to the token endpoint, out of band, nothing forwarded. Thecodethe agent receives is an inert sentinel. The real code is replaced whether or not marshal’s own exchange succeeded; if it failed, the agent must not get the chance either, and the failure surfaces as a refused API request naming the cause. - The agent’s own token request is answered locally and never forwarded — a well-formed
token response carrying a sentinel. Its state machine completes normally, on nothing. That
response, like every response marshal synthesizes, carries
proxy-agent: bot-marshal.
The sentinel is not a placeholder to be recognised later. Injection is unconditional, so whatever the agent presents to the API is overwritten with the real token regardless.
Why client_id (and client_secret) are still required. Step 2 is not a relay of the
agent’s own token request — marshal performs its own call to token_endpoint, and every
token request needs a client_id regardless of whether the client is confidential or public
(RFC 6749 §4.1.3). Marshal has to authenticate to the provider as the same registered
application the agent already has, because that is the application whose PKCE challenge it
just replaced. This is the real distinction between this and bootstrap capture below:
in_band requires you to already know that application’s client_id (and client_secret
unless it’s a public client, client_auth: none); bootstrap capture exists for exactly the
case where you don’t.
The agent’s own authorization request does carry a client_id — step 1 reads that same
request’s state and redirect_uri straight off it. Marshal does not also read client_id
from it, on purpose: doing so would let the untrusted party being excluded from the credential
pick which configured credential marshal presents on its behalf, the same category of thing
ADR-0009 refuses elsewhere. It also
would not remove the config for the common case anyway — client_secret is never present in
the authorization request, only in a token request, and step 3 above never forwards the
agent’s own token request, so marshal never observes it. Harvesting
client_id from the request would only ever spare client_auth: none clients from declaring
it, which is not worth a special case.
Marshal also keeps the refresh token the exchange produced, exactly as
marshal secrets oauth login would — so the credential survives a restart without the agent
ever authorising again.
What it costs, and when not to use it:
- Only
grant: authorization_code. The other grants have no authorization flow to take over. - Both endpoints must be
https. Capture depends on marshal seeing the response, which requires the connection to be intercepted; a plainhttprequest through the explicit proxy is relayed instead. This is a config error, not a silent no-op. - A client that checks its own flow breaks. One that verifies the challenge in the authorization URL matches the one it generated, or validates the token response against a nonce, will fail — correctly, from its point of view. There is no way to support both.
- It is best-effort. The provider redirects whoever made the authorization request. If that
is a browser rather than the agent’s HTTP client, the browser must also be behind the proxy.
An authorization request made outside the proxy’s capture is never rewritten at all.
marshal secrets oauth loginis the guaranteed path; this is the convenient one.
capture defaults to off. See ADR-0032 and
ADR-0031.
Bootstrap capture
Section titled “Bootstrap capture”Everything above — including in_band — assumes marshal already knows the OAuth application:
its client_id, its endpoints. Bootstrap capture is for the case it doesn’t, which is the
common one for a vendor’s own CLI subscription login: the application belongs to the vendor,
is not published, and there is no { type: oauth2 } source to declare in the first place.
Bootstrap needs no OAuth source declaration. It uses the base config, CA and top-level
state_dir: described in the walkthrough below:
marshal secrets oauth login CLAUDE_SUBSCRIPTION --mode steal --run -- some-vendor-cli loginInstead of driving a flow it already knows, marshal starts a disposable, foreground proxy
instance and watches for the tool’s own token exchange — the request the tool’s own process
makes to redeem its code. That single request carries everything worth knowing (client_id,
redirect_uri, and — as its own destination — token_endpoint itself), which is why nothing
needs to be declared beforehand. It matches on the shape of the request (a POST whose body
parses as grant_type=authorization_code or the device-code grant) rather than a configured
host and path, because by definition it does not know the host or path yet. The body is read
per its own Content-Type — application/x-www-form-urlencoded per RFC 6749, or
application/json, since plenty of real clients send that instead.
That looseness is safe here specifically because this proxy exists for one command, in the
foreground, under a timeout, with somebody watching — not as a standing part of serve.
--mode decides what happens to the exchange it matches:
observe(default) — forwards it untouched. The tool’s own login succeeds normally and keeps its own working credential; marshal simply also learns one. It reads the response whateverContent-Encodingthe real provider used (gzip and deflate;br, since a modern client usually advertises it too) — the mitm layer itself never decodes bodies, so this is the one place in this crate that does, because it is inspecting somebody else’s exchange rather than making its own.steal— redeems the code out of band itself and answers the tool with a sentinel, so the tool never ends up holding a working credential — at the cost of its login reporting failure, which from its point of view is exactly what happened.
Either way, a refresh token the exchange produced is written under state_dir, and the
configuration it discovered — endpoint, client_id, scope — is written as a named transform
bundle under transforms_path (see marshal secrets oauth login --wait/--run
for exactly what), ready to attach to a profile’s transforms: list if you want an ongoing
declared swap afterward. Bootstrap capture only seeds a credential once; it is not itself a
standing part of the runtime.
Full command reference, flags, and the sandboxing --run applies — including why
--isolation netns does not work for a flow that opens a browser and waits on a loopback
callback, and what to use instead — see
marshal secrets oauth login <name> --wait/--run.
See also ADR-0034.
From bootstrap to an authenticated request
Section titled “From bootstrap to an authenticated request”Use this sequence for a vendor CLI whose application details you do not control. Do the
supervised login as a trusted operator. Its default observe mode leaves a working credential
in the tool’s own store too; do not treat that tool environment as credential-free afterward.
- Start with a base config, a generated CA and a private
state_dir. The getting-started config supplies the first two; addstate_dir: "~/.local/state/bot-marshal"at the base-file level. Use the same config path and account for bootstrap and the eventual proxy, or the stored grant may be missing. - Run
marshal secrets oauth login SERVICE --wait. In the tool’s terminal, apply the printed proxy/trust settings and run its login. Alternatively use--run --isolation cgroupfor browser/loopback flows on Linux. Do not use defaultnetnsfor that shape. - On success, open
transforms/SERVICE.yamlbeside the config (or intransforms_path). Replace itsruleshost"..."with the resource API host. Bootstrap knows the token endpoint, not the API destination. If the file already existed it was not overwritten; apply the printed configuration to it deliberately. - Attach
transforms: [SERVICE]to a profile and allow the API host in its policy. A profile cannot combine named bundles with embedded request/response transforms: move existing inline transforms into a named bundle and list both bundles if needed. - Run
marshal config checkon this enrolled host. A bootstrap-generated authorization-code source can omit the authorization endpoint only while its stored grant exists. A clean CI machine has no grant: use a separately declared portable config with endpoint/loopback redirect fields for validation, or validate the generated file on its enrolled host. - Start
serve, or reload an already-running proxy if only reloadable configuration changed. A changed env file,state_diror forwarding guard requires restart. Send an allowed API request through the proxy with a client that trusts the CA. Inspect the audit reason, upstream status and secret-injection facts; a schema check alone is not authentication.
For a concrete resource example, once SERVICE has been captured and its bundle’s host is
api.example.com, this is a complete named profile:
default_action: denytransforms: [SERVICE]policy: - layer: allowlist allow: { domains: ["api.example.com"] } on_match: allow on_miss: passRun it through the fallback only if that is intentional; attributed clients need a resolver
mapping to service-agent. A shell request with no resolver continues to use the embedded
profile. For a smoke test of this named profile, temporarily use serve --profile service-agent to select it for unattributed connections, then restore your intended fallback.
Use the provider’s real resource host and path in place of api.example.com:
marshal serve --profile service-agent --audit-log /tmp/service-audit.jsonl# second terminal:curl --cacert ~/.config/bot-marshal/ca.crt -x http://127.0.0.1:8080 https://api.example.com/The agent need not present the real credential: marshal injects it after policy allows. An
upstream auth error can still mean insufficient scope, a missing companion claim header, or a
required token_exchange; configure those from the provider’s requirements. Review the audit
file without printing live tokens, and remove the scratch audit when finished.
An ID token claim as a second header
Section titled “An ID token claim as a second header”Most providers put everything a resource server needs to authorize a request in the access
token itself. Some do not: OpenAI’s ChatGPT sign-in issues an access token that authenticates a
session, and separately expects a ChatGPT-Account-ID header naming which of that account’s
workspaces the request is for — taken from a claim inside the ID token the same token response
carried. A swap injecting only Authorization: Bearer gets a 401 from the resource server
even though the token is live, current, and correctly scoped, because from the resource
server’s side there is no account to authorize against until it sees that header too.
type: oauth2_claim reads a value out of another oauth2 swap’s ID token rather than minting
one of its own. This example assumes an already enrolled bootstrap grant: its recorded
provider callback is not loopback, so it cannot be used to start oauth login directly.
The CLI-driven authorization-code flow requires a loopback redirect URI.
request_transforms: secrets: - name: CODEX_SUBSCRIPTION source: type: oauth2 grant: authorization_code token_endpoint: https://auth.openai.com/oauth/token client_id: app_EMoamEEZ73f0CkXaXp7hrann redirect_uri: https://auth.openai.com/deviceauth/callback client_auth: none inject: { type: bearer } rules: [{ host: "api.openai.com" }]
- name: CODEX_ACCOUNT_ID source: type: oauth2_claim of: CODEX_SUBSCRIPTION claim: ["https://api.openai.com/auth", "chatgpt_account_id"] inject: { type: header, name: ChatGPT-Account-ID } rules: [{ host: "api.openai.com" }]| field | |
|---|---|
of | the other swap’s name:. Must appear earlier in secrets: — config is built top to bottom, and the claim swap needs the other one already built to share its cache rather than minting a second, independent copy of the same credential |
claim | a list of object keys to walk into the ID token’s claims, not a single dotted string or a JSON Pointer — a claim is commonly itself namespaced by a URL (https://api.openai.com/auth) that would need escaping in either of those |
The two swaps share one Oauth2Source instance: CODEX_ACCOUNT_ID mints nothing itself, it
only reads the ID token that came back the last time CODEX_SUBSCRIPTION minted or refreshed —
triggering a mint first if nothing is cached yet, so it works correctly as the very first
request too, not only after some unrelated request already warmed the cache.
The signature on the ID token is not checked. There is nothing to check it against: this token was not presented by a client asking to be trusted, it was returned to marshal directly by the provider’s own token endpoint, over the TLS connection marshal itself just made, seconds ago. The only question left is what one field of it says.
A provider that issues no id_token alongside its access token, or whose ID token has nothing
at the given claim path, fails the request the same way a missing environment variable
would — named, at the exact path that had nothing under it, not a bare “not found”.
A token exchange for a different credential
Section titled “A token exchange for a different credential”The claim source above assumes the grant’s own access token is the right credential and only a
second header is missing. Some providers issue an access token that is not the right
credential at all: OpenAI’s ChatGPT sign-in hands back a token that authenticates a session, and
a resource server like api.openai.com wants a different token, obtained by exchanging that
session for one — an RFC 8693 token exchange. Without
it, requests fail with a missing-scope error that reads exactly like a configuration mistake,
on a token that is perfectly live and correctly obtained.
token_exchange runs this as a second call, immediately after every mint, and it is the
exchanged token that gets cached and injected — not the grant’s own:
This source fragment also assumes the previously enrolled bootstrap grant; its provider callback cannot start the CLI loopback login flow.
source: type: oauth2 grant: authorization_code token_endpoint: https://auth.openai.com/oauth/token client_id: app_EMoamEEZ73f0CkXaXp7hrann redirect_uri: https://auth.openai.com/deviceauth/callback client_auth: none token_exchange: subject: id_token extra_params: { requested_token: "openai-api-key" }| field | |
|---|---|
subject | which of the grant’s tokens is presented as subject_token: id_token (default — what OpenAI’s exchange needs) or access_token |
extra_params | anything the provider’s exchange wants beyond the RFC’s own fields. OpenAI’s, for instance, says what it wants back with a non-standard requested_token field rather than the RFC’s requested_token_type URI |
The session’s id_token survives the exchange onto the cached result, so a type: oauth2_claim
source reading account-routing information out of it (the section above) still works with
token_exchange set on the same swap — the exchange response is not expected to carry its own
ID token, and the claim source has no use for the exchanged credential’s claims even if it did.
The exchange is not verified against anything beyond what post_token already does for every
grant: same client authentication, same timeout, same redaction of whatever comes back. See
ADR-0039 for why this runs on
every mint rather than once at enrolment.
What this costs
Section titled “What this costs”A request can block on a third party. Minting happens on the request path, so a slow token
endpoint makes the first request after an expiry slow — bounded by timeout, because an
endpoint that accepts a connection and then goes silent would otherwise hang the request
indefinitely while every other request for that credential queued behind it. Failure is closed:
a request whose credential cannot be minted is refused, with the provider’s own error and
error_description in the 403 body, never forwarded unauthenticated.
A revoked token is not noticed until it expires. Nothing invalidates a cached token when an
upstream rejects it, so a credential revoked at the provider ahead of its stated expiry goes on
being presented until the cached copy ages out. marshal secrets oauth refresh <name> mints
a token in the CLI process to test the credential; it does not clear the running proxy’s
in-memory cache. Restart serve to discard its cached access tokens. Similarly, logout
removes the stored grant on disk but does not erase a grant or access token already cached by
a running proxy. For a suspected leak, revoke the credential at the provider and stop/restart
the proxy; logout is not provider revocation.
Concurrent requests on an expired token mint once, not once each — some providers invalidate the previous refresh token on every use, which turns a concurrent double refresh into a broken credential rather than merely a wasted round trip.
token_exchange doubles the round trips on a cache miss. The exchange is a second call to
the same endpoint, bounded by the same timeout and failing closed the same way, but the
first-request-after-expiry latency above is now roughly two token-endpoint calls, not one.
The token endpoint obeys upstream.deny_cidrs and upstream.allow_private, the same
rules that constrain agent egress. A token endpoint on the public internet is unaffected; an
internal auth server on RFC1918 needs upstream.allow_private: true, which also opens agent
egress to private addresses. A refusal names the exact rule that blocked it.
An OAuth2 swap never injects into its own endpoints. The token_endpoint and
authorization_endpoint are excluded from injection automatically, whatever rules says —
they are frequently on the same host as the API. The authorization request is by construction
the one request in the flow that is not yet authenticated, and under capture: in_band setting
a credential on it is also circular: injecting means minting, minting needs the credential, and
the credential is what the request exists to obtain. The exclusion is recorded in the evidence
trail as secrets.not_injected.<host><path> rather than being silent.
tls.upstream_ca_certs applies to marshal’s own calls too. An internal auth server behind a
private CA works without further configuration: the roots the proxy trusts for upstream traffic
are the roots marshal trusts when it calls a token endpoint for itself.
Every credential in play is redacted — the tokens a provider returns, and the client secret, signing key and refresh token marshal presents. A minted token is redacted from the moment it is minted rather than from startup; see ADR-0029.
Nothing is minted at boot, so starting the proxy never depends on an auth server being reachable, never creates a credential nobody asked for, and — against a provider that rotates refresh tokens — never consumes a rotation just by starting or reloading.
Secrets are redacted in every audit path and log line.
Within a swap’s host scope, every allowed request is authenticated — not just ones the
agent tried to authenticate. rules is therefore the entire trust boundary for that
credential, not host-allowlist-plus-something-else. Scope a swap as narrowly as the endpoint
that actually needs it. See
ADR-0027 for the full reasoning.