Skip to content

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 applicationyou don’t (a vendor’s own client)
no interactive login neededa { type: oauth2 } source with grant: client_credentials, refresh_token, or jwt_bearer — authenticates from config alone, below—
a human logs in once, marshal drives itgrant: 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 unattendedsource.capture: in_band, § In-band capture — marshal takes the flow over so the agent gets nothingnot 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_endpointthe full URL, path included
client_id
grantclient_credentials (default), refresh_token, jwt_bearer, authorization_code, device_code
client_authclient_secret_basic (default), client_secret_post, private_key_jwt, none
client_secretitself 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_tokena source. Required by grant: refresh_token; meaningless for the others
scopea list, joined with spaces per RFC 6749
audiencesent as audience= when set
extra_paramsname/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_skewsubtracted from the stated lifetime so a token cannot expire in flight. Defaults to 60s
timeouthow long any single call to the provider may take. Defaults to 10s

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_keya source yielding a PEM. Required by jwt_bearer and private_key_jwt
algorithmRS256 (default) or ES256. HS256 is deliberately absent — it is a shared secret wearing asymmetric clothes, so it offers nothing over client_secret_basic
key_idthe assertion’s kid header, for a provider publishing more than one key
issuerthe assertion’s iss. Defaults to client_id
subjectthe assertion’s sub. Defaults to issuer; set it to an impersonated user for Google’s domain-wide delegation
assertion_audiencethe assertion’s aud. Defaults to token_endpoint
assertion_lifetimedefaults 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_endpointrequired by authorization_code, unless the swap is already enrolled — see below
redirect_urirequired 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_endpointrequired 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.

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:

  1. The agent’s authorization request has its PKCE challenge replaced with one marshal derived from a verifier only marshal holds. Its state and redirect_uri are 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.
  2. The redirect never reaches the agent with a real code in it. Marshal intercepts the Location header, lifts the code out, and completes the exchange itself — a direct call to the token endpoint, out of band, nothing forwarded. The code the 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.
  3. 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 plain http request 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 login is the guaranteed path; this is the convenient one.

capture defaults to off. See ADR-0032 and ADR-0031.

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:

Terminal window
marshal secrets oauth login CLAUDE_SUBSCRIPTION --mode steal --run -- some-vendor-cli login

Instead 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 whatever Content-Encoding the 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.

  1. Start with a base config, a generated CA and a private state_dir. The getting-started config supplies the first two; add state_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.
  2. 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 cgroup for browser/loopback flows on Linux. Do not use default netns for that shape.
  3. On success, open transforms/SERVICE.yaml beside the config (or in transforms_path). Replace its rules host "..." 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.
  4. 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.
  5. Run marshal config check on 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.
  6. Start serve, or reload an already-running proxy if only reloadable configuration changed. A changed env file, state_dir or 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:

profiles/service-agent.yaml
default_action: deny
transforms: [SERVICE]
policy:
- layer: allowlist
allow: { domains: ["api.example.com"] }
on_match: allow
on_miss: pass

Run 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:

Terminal window
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.

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
ofthe 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
claima 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
subjectwhich of the grant’s tokens is presented as subject_token: id_token (default — what OpenAI’s exchange needs) or access_token
extra_paramsanything 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.

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.