[object Object]

← back to Cli Printing Press

feat(cli): OAuth2 client_credentials grant + bearer_token AuthHeader precedence fix (#528)

593ce970a252630f7da42f78dcb885564bf518da · 2026-05-03 02:24:11 -0700 · Trevin Chow

* feat(cli): AuthConfig.OAuth2Grant field + validation

Add a discriminator field on AuthConfig for OAuth2 sub-flows. Defaults to
"authorization_code" (the existing 3-legged user-OAuth flow with callback
server) when empty. "client_credentials" enables the 2-legged
server-to-server flow used by Auth0 Management, Microsoft Graph daemon
apps, FedEx, etc. — POST to TokenURL with form-encoded credentials, no
user redirect, no refresh_token.

This commit only adds the field, the EffectiveOAuth2Grant() helper, the
public OAuth2Grant* constants, and validation. Generator templates that
branch on the grant ship in subsequent commits (U3, U4 of the OAuth2
client_credentials WU plan).

Field is meaningless for non-oauth2 Auth.Type values; validation
intentionally does not cross-check type/grant combos to keep the
validator simple. Templates that consume the field should ignore it for
non-oauth2 types, mirroring how SessionTTLHours and similar fields
behave.

Refs #525 (FedEx retro WU-1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cli): OpenAPI parser detects oauth2 client_credentials flow

When a securityScheme of type oauth2 declares a clientCredentials flow,
the parser now sets Auth.OAuth2Grant to "client_credentials" alongside
the existing TokenURL + Scopes extraction. AuthorizationURL stays empty
because the cc flow has no user redirect.

When a single scheme declares both authorizationCode and clientCredentials
flows, clientCredentials wins. Server-to-server is the more common shape
for printed CLIs (which run in CI/scripts, not interactive browsers); the
spec author can override post-import by setting OAuth2Grant explicitly.
Documented in a code comment so future readers don't reorder accidentally.

When a clientCredentials block exists but its tokenUrl is empty
(malformed spec), the parser falls through to the next flow rather than
crashing. Tested as TestParseOAuth2ClientCredentialsMissingTokenURLSkipsBranch.

Auth.Type stays at "bearer_token" (the existing parser convention for all
oauth2 schemes) — OAuth2Grant is the discriminator that downstream
templates branch on.

Refs #525 (FedEx retro WU-1, U2 of the implementation plan).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cli): generator emits OAuth2 client_credentials login command

Adds a new auth template (auth_client_credentials.go.tmpl) and wires its
selection in the generator's per-spec template-choice ladder. Specs with
auth.OAuth2Grant=client_credentials and a non-empty TokenURL get a
generated `auth login` command that:

  - POSTs grant_type=client_credentials&client_id=&client_secret= to the
    spec's TokenURL with form-encoded body
  - Defaults --client-id / --client-secret to the spec's first two
    auth.env_vars (FedEx pattern: FEDEX_API_KEY / FEDEX_SECRET_KEY)
  - Caches the access_token + expires_in via cfg.SaveTokens
  - Short-circuits under PRINTING_PRESS_VERIFY=1 (cliutil.IsVerifyEnv)
    so shipcheck side-effect detection treats it as safe
  - Carries Annotations{"mcp:hidden": "true"} so the MCP surface skips
    it (it's a setup command, not an agent tool)

Template selection priority is documented inline:
  1. OAuth2 client_credentials (server-to-server, no user redirect)
  2. OAuth2 authorization_code (3-legged, AuthorizationURL non-empty)
  3. Browser-cookie / composed / persisted-query
  4. Simple token-management (catch-all)

The new template emits its own mintClientCredentialsToken helper —
client.go.tmpl will reuse the same algorithm in U4 for proactive
refresh, but the helper lives with the login command since that's its
primary call site.

Tests:
  - TestGenerateOAuth2ClientCredentialsAuthTemplate pins the new
    template's contents (login command, token URL, env-var defaults,
    verify-env short-circuit, MCP-hidden annotation).
  - TestGenerateOAuth2AuthorizationCodeRegression pins the existing
    3-legged path: spec with AuthorizationURL=non-empty + no
    OAuth2Grant continues to select auth.go.tmpl, NOT the new template.

Refs #525 (FedEx retro WU-1, U3 of the implementation plan).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cli): config + client templates honor oauth2 client_credentials

Three related changes in the auth-flow plumbing for OAuth2
client_credentials specs:

1. config.go.tmpl bearer_token precedence reorder. AccessToken (cached
   after auth login or set-token) now wins over the env-var-as-bearer
   fallback. Was the FedEx "Invalid CXS JWT" bug class: env var holds
   the Client ID for OAuth2 specs, and sending it as Bearer 401s every
   request after a successful login. The env-var fallback remains for
   the simple case where the env var IS a usable bearer JWT (personal
   access tokens, GitHub PATs).

2. client.go.tmpl proactive client_credentials refresh. When
   Auth.OAuth2Grant=client_credentials, the client emits new helpers
   needsClientCredentialsMint, resolveClientCredentials, and
   mintClientCredentials. authHeader() consults them on each request
   and re-mints via the same grant when the cached token is missing or
   within a 60-second safety window of expiry.

3. cookie / composed AuthHeader behavior unchanged. Only bearer_token
   flipped; cookie and composed still check env-var first.

Tests:
  - TestAuthHeader_BearerTokenAccessTokenWinsOverEnv (new precedence)
  - TestAuthHeader_EnvVarWinsOverFileTokenForCookieComposed (unchanged)
  - TestGenerateOAuth2ClientCredentialsClientRefresh
  - TestGenerateOAuth2AuthorizationCodeClientRefreshUnchanged

Refs #525 (FedEx retro WU-1, U4 of the implementation plan).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(cli): simplify OAuth2 client_credentials surface from /simplify pass

- Drop local truncateForError; use cliutil.SanitizeErrorBody so error
  bodies are also redacted of credential-shaped strings.
- Drop unused tokenResponse.TokenType / Scope fields.
- Trim over-documented OAuth2Grant doc comments and the bearer_token
  AuthHeader precedence comment.
- Drop incident-name reference per AGENTS.md (no incident references in
  code comments).

No behavior change.

* fix(cli): scope bearer_token AccessToken-first precedence to client_credentials

Review surfaced that the bearer_token AuthHeader precedence flip applied to
all bearer_token CLIs unconditionally, regressing the env-first convention
established for plain bearer_token / PAT-style flows. Gate the new precedence
on EffectiveOAuth2Grant=client_credentials so:

- client_credentials: cached AccessToken wins (env var is the Client ID,
  not a bearer)
- plain bearer_token, cookie, composed: env-first preserved (kubectl/gh/aws
  convention; freshly-rotated env var must not be silently shadowed by a
  stale on-disk AccessToken)

Also from the review pass:

- generator.go: read OAuth2Grant via EffectiveOAuth2Grant() to honor the
  default-lives-in-one-place rule the spec doc states
- auth_client_credentials.go.tmpl: drop misleading comment claiming the
  helper is shared with the client refresh path (it isn't; client.go.tmpl
  has its own parallel mintClientCredentials)
- auth_env_precedence_test.go: split into two tests pinning both sides of
  the gating (client_credentials AccessToken-first; bearer/cookie/composed
  env-first)

* feat(cli): serialize OAuth2 client_credentials token mints with sync.Mutex

Without serialization, N parallel API calls inside the 60s pre-expiry
window each see needsClientCredentialsMint == true, each call
mintClientCredentials concurrently, race on *config.Config field writes,
and concurrently os.WriteFile to the same config path. Go's race detector
would fire on -race; production behavior is benign extra mints + a final
last-writer-wins file (the OS write is atomic for small payloads), but
the data race itself is real.

Add a per-grant ccMu sync.Mutex on the Client struct (only emitted when
EffectiveOAuth2Grant=client_credentials) and use a double-checked lock in
authHeader so only one goroutine mints under contention.

* test(cli): add golden case for OAuth2 client_credentials auth shape

AGENTS.md (Golden Output Harness) requires extending the golden suite
when the machine gains a new auth shape. Adds:

- testdata/golden/fixtures/golden-api-oauth2-cc.yaml — minimal spec
  declaring only flows.clientCredentials.
- testdata/golden/cases/generate-golden-api-oauth2-cc/ — runs generate
  and snapshots the three files where the auth shape contract lives:
  internal/cli/auth.go (auth_client_credentials.go.tmpl output),
  internal/client/client.go (mint helpers, ccMu mutex, double-checked
  lock), internal/config/config.go (AuthHeader AccessToken-first gated
  branch).

Locks the parser → generator → templates pipeline against silent
regression. If a future refactor breaks the OAuth2Grant gating, the
template selection ladder, or the AuthHeader precedence, the case
fails with a content diff.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 593ce970a252630f7da42f78dcb885564bf518da
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun May 3 02:24:11 2026 -0700

    feat(cli): OAuth2 client_credentials grant + bearer_token AuthHeader precedence fix (#528)
    
    * feat(cli): AuthConfig.OAuth2Grant field + validation
    
    Add a discriminator field on AuthConfig for OAuth2 sub-flows. Defaults to
    "authorization_code" (the existing 3-legged user-OAuth flow with callback
    server) when empty. "client_credentials" enables the 2-legged
    server-to-server flow used by Auth0 Management, Microsoft Graph daemon
    apps, FedEx, etc. — POST to TokenURL with form-encoded credentials, no
    user redirect, no refresh_token.
    
    This commit only adds the field, the EffectiveOAuth2Grant() helper, the
    public OAuth2Grant* constants, and validation. Generator templates that
    branch on the grant ship in subsequent commits (U3, U4 of the OAuth2
    client_credentials WU plan).
    
    Field is meaningless for non-oauth2 Auth.Type values; validation
    intentionally does not cross-check type/grant combos to keep the
    validator simple. Templates that consume the field should ignore it for
    non-oauth2 types, mirroring how SessionTTLHours and similar fields
    behave.
    
    Refs #525 (FedEx retro WU-1).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * feat(cli): OpenAPI parser detects oauth2 client_credentials flow
    
    When a securityScheme of type oauth2 declares a clientCredentials flow,
    the parser now sets Auth.OAuth2Grant to "client_credentials" alongside
    the existing TokenURL + Scopes extraction. AuthorizationURL stays empty
    because the cc flow has no user redirect.
    
    When a single scheme declares both authorizationCode and clientCredentials
    flows, clientCredentials wins. Server-to-server is the more common shape
    for printed CLIs (which run in CI/scripts, not interactive browsers); the
    spec author can override post-import by setting OAuth2Grant explicitly.
    Documented in a code comment so future readers don't reorder accidentally.
    
    When a clientCredentials block exists but its tokenUrl is empty
    (malformed spec), the parser falls through to the next flow rather than
    crashing. Tested as TestParseOAuth2ClientCredentialsMissingTokenURLSkipsBranch.
    
    Auth.Type stays at "bearer_token" (the existing parser convention for all
    oauth2 schemes) — OAuth2Grant is the discriminator that downstream
    templates branch on.
    
    Refs #525 (FedEx retro WU-1, U2 of the implementation plan).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * feat(cli): generator emits OAuth2 client_credentials login command
    
    Adds a new auth template (auth_client_credentials.go.tmpl) and wires its
    selection in the generator's per-spec template-choice ladder. Specs with
    auth.OAuth2Grant=client_credentials and a non-empty TokenURL get a
    generated `auth login` command that:
    
      - POSTs grant_type=client_credentials&client_id=&client_secret= to the
        spec's TokenURL with form-encoded body
      - Defaults --client-id / --client-secret to the spec's first two
        auth.env_vars (FedEx pattern: FEDEX_API_KEY / FEDEX_SECRET_KEY)
      - Caches the access_token + expires_in via cfg.SaveTokens
      - Short-circuits under PRINTING_PRESS_VERIFY=1 (cliutil.IsVerifyEnv)
        so shipcheck side-effect detection treats it as safe
      - Carries Annotations{"mcp:hidden": "true"} so the MCP surface skips
        it (it's a setup command, not an agent tool)
    
    Template selection priority is documented inline:
      1. OAuth2 client_credentials (server-to-server, no user redirect)
      2. OAuth2 authorization_code (3-legged, AuthorizationURL non-empty)
      3. Browser-cookie / composed / persisted-query
      4. Simple token-management (catch-all)
    
    The new template emits its own mintClientCredentialsToken helper —
    client.go.tmpl will reuse the same algorithm in U4 for proactive
    refresh, but the helper lives with the login command since that's its
    primary call site.
    
    Tests:
      - TestGenerateOAuth2ClientCredentialsAuthTemplate pins the new
        template's contents (login command, token URL, env-var defaults,
        verify-env short-circuit, MCP-hidden annotation).
      - TestGenerateOAuth2AuthorizationCodeRegression pins the existing
        3-legged path: spec with AuthorizationURL=non-empty + no
        OAuth2Grant continues to select auth.go.tmpl, NOT the new template.
    
    Refs #525 (FedEx retro WU-1, U3 of the implementation plan).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * feat(cli): config + client templates honor oauth2 client_credentials
    
    Three related changes in the auth-flow plumbing for OAuth2
    client_credentials specs:
    
    1. config.go.tmpl bearer_token precedence reorder. AccessToken (cached
       after auth login or set-token) now wins over the env-var-as-bearer
       fallback. Was the FedEx "Invalid CXS JWT" bug class: env var holds
       the Client ID for OAuth2 specs, and sending it as Bearer 401s every
       request after a successful login. The env-var fallback remains for
       the simple case where the env var IS a usable bearer JWT (personal
       access tokens, GitHub PATs).
    
    2. client.go.tmpl proactive client_credentials refresh. When
       Auth.OAuth2Grant=client_credentials, the client emits new helpers
       needsClientCredentialsMint, resolveClientCredentials, and
       mintClientCredentials. authHeader() consults them on each request
       and re-mints via the same grant when the cached token is missing or
       within a 60-second safety window of expiry.
    
    3. cookie / composed AuthHeader behavior unchanged. Only bearer_token
       flipped; cookie and composed still check env-var first.
    
    Tests:
      - TestAuthHeader_BearerTokenAccessTokenWinsOverEnv (new precedence)
      - TestAuthHeader_EnvVarWinsOverFileTokenForCookieComposed (unchanged)
      - TestGenerateOAuth2ClientCredentialsClientRefresh
      - TestGenerateOAuth2AuthorizationCodeClientRefreshUnchanged
    
    Refs #525 (FedEx retro WU-1, U4 of the implementation plan).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * refactor(cli): simplify OAuth2 client_credentials surface from /simplify pass
    
    - Drop local truncateForError; use cliutil.SanitizeErrorBody so error
      bodies are also redacted of credential-shaped strings.
    - Drop unused tokenResponse.TokenType / Scope fields.
    - Trim over-documented OAuth2Grant doc comments and the bearer_token
      AuthHeader precedence comment.
    - Drop incident-name reference per AGENTS.md (no incident references in
      code comments).
    
    No behavior change.
    
    * fix(cli): scope bearer_token AccessToken-first precedence to client_credentials
    
    Review surfaced that the bearer_token AuthHeader precedence flip applied to
    all bearer_token CLIs unconditionally, regressing the env-first convention
    established for plain bearer_token / PAT-style flows. Gate the new precedence
    on EffectiveOAuth2Grant=client_credentials so:
    
    - client_credentials: cached AccessToken wins (env var is the Client ID,
      not a bearer)
    - plain bearer_token, cookie, composed: env-first preserved (kubectl/gh/aws
      convention; freshly-rotated env var must not be silently shadowed by a
      stale on-disk AccessToken)
    
    Also from the review pass:
    
    - generator.go: read OAuth2Grant via EffectiveOAuth2Grant() to honor the
      default-lives-in-one-place rule the spec doc states
    - auth_client_credentials.go.tmpl: drop misleading comment claiming the
      helper is shared with the client refresh path (it isn't; client.go.tmpl
      has its own parallel mintClientCredentials)
    - auth_env_precedence_test.go: split into two tests pinning both sides of
      the gating (client_credentials AccessToken-first; bearer/cookie/composed
      env-first)
    
    * feat(cli): serialize OAuth2 client_credentials token mints with sync.Mutex
    
    Without serialization, N parallel API calls inside the 60s pre-expiry
    window each see needsClientCredentialsMint == true, each call
    mintClientCredentials concurrently, race on *config.Config field writes,
    and concurrently os.WriteFile to the same config path. Go's race detector
    would fire on -race; production behavior is benign extra mints + a final
    last-writer-wins file (the OS write is atomic for small payloads), but
    the data race itself is real.
    
    Add a per-grant ccMu sync.Mutex on the Client struct (only emitted when
    EffectiveOAuth2Grant=client_credentials) and use a double-checked lock in
    authHeader so only one goroutine mints under contention.
    
    * test(cli): add golden case for OAuth2 client_credentials auth shape
    
    AGENTS.md (Golden Output Harness) requires extending the golden suite
    when the machine gains a new auth shape. Adds:
    
    - testdata/golden/fixtures/golden-api-oauth2-cc.yaml — minimal spec
      declaring only flows.clientCredentials.
    - testdata/golden/cases/generate-golden-api-oauth2-cc/ — runs generate
      and snapshots the three files where the auth shape contract lives:
      internal/cli/auth.go (auth_client_credentials.go.tmpl output),
      internal/client/client.go (mint helpers, ccMu mutex, double-checked
      lock), internal/config/config.go (AuthHeader AccessToken-first gated
      branch).
    
    Locks the parser → generator → templates pipeline against silent
    regression. If a future refactor breaks the OAuth2Grant gating, the
    template selection ladder, or the AuthHeader precedence, the case
    fails with a content diff.
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 internal/generator/auth_env_precedence_test.go     |  80 +++-
 internal/generator/generator.go                    |  21 +-
 internal/generator/generator_test.go               | 194 ++++++++
 .../templates/auth_client_credentials.go.tmpl      | 248 ++++++++++
 internal/generator/templates/client.go.tmpl        | 101 ++++
 internal/generator/templates/config.go.tmpl        |  28 ++
 internal/openapi/parser.go                         |  15 +-
 internal/openapi/parser_test.go                    | 131 ++++++
 internal/spec/spec.go                              |  40 ++
 internal/spec/spec_test.go                         |  58 +++
 .../generate-golden-api-oauth2-cc/artifacts.txt    |  26 ++
 .../generate-golden-api-oauth2-cc/command.txt      |   2 +
 .../generate-golden-api-oauth2-cc/exit.txt         |   1 +
 .../printing-press-oauth2-cc/internal/cli/auth.go  | 218 +++++++++
 .../internal/client/client.go                      | 515 +++++++++++++++++++++
 .../internal/config/config.go                      | 125 +++++
 .../generate-golden-api-oauth2-cc/stderr.txt       |   1 +
 .../generate-golden-api-oauth2-cc/stdout.txt       |   0
 testdata/golden/fixtures/golden-api-oauth2-cc.yaml |  47 ++
 19 files changed, 1822 insertions(+), 29 deletions(-)

diff --git a/internal/generator/auth_env_precedence_test.go b/internal/generator/auth_env_precedence_test.go
index 4c8a6af3..18650b0d 100644
--- a/internal/generator/auth_env_precedence_test.go
+++ b/internal/generator/auth_env_precedence_test.go
@@ -11,10 +11,47 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-// TestAuthHeader_EnvVarWinsOverFileToken pins that the generated
-// Config.AuthHeader() checks the env-var-backed field BEFORE the
-// file-stored AccessToken for bearer_token, cookie, and composed auth
-// types — env > config convention (kubectl, gh, aws, gcloud).
+// TestAuthHeader_ClientCredentialsAccessTokenWinsOverEnv pins that under
+// OAuth2 client_credentials the cached AccessToken wins over the env-var
+// fallback. The env var is the Client ID, not a usable bearer JWT.
+func TestAuthHeader_ClientCredentialsAccessTokenWinsOverEnv(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("cc-precedence")
+	apiSpec.Auth = spec.AuthConfig{
+		Type:        "bearer_token",
+		Header:      "Authorization",
+		EnvVars:     []string{"CC_AUTH_TEST_CLIENT_ID"},
+		OAuth2Grant: spec.OAuth2GrantClientCredentials,
+		TokenURL:    "https://example.com/token",
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "cc-precedence-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+	require.NoError(t, err)
+	content := string(cfgSrc)
+
+	envCheck := "if c." + resolveEnvVarField("CC_AUTH_TEST_CLIENT_ID") + ` != ""`
+	tokenCheck := `if c.AccessToken != ""`
+
+	require.Contains(t, content, envCheck, "AuthHeader must keep the env-var fallback")
+	require.Contains(t, content, tokenCheck, "AuthHeader must check AccessToken")
+
+	body := authHeaderBody(t, content)
+	envIdx := strings.Index(body, envCheck)
+	tokenIdx := strings.Index(body, tokenCheck)
+	require.NotEqual(t, -1, envIdx)
+	require.NotEqual(t, -1, tokenIdx)
+	assert.Less(t, tokenIdx, envIdx,
+		"AccessToken check must appear BEFORE env-var fallback under OAuth2 client_credentials")
+}
+
+// TestAuthHeader_EnvVarWinsOverFileToken pins env-first precedence for
+// the non-client_credentials cases — plain bearer_token (PAT-style),
+// cookie, and composed all follow the env > config convention so a
+// freshly-rotated env var wins over a stale on-disk AccessToken.
 func TestAuthHeader_EnvVarWinsOverFileToken(t *testing.T) {
 	t.Parallel()
 
@@ -49,27 +86,28 @@ func TestAuthHeader_EnvVarWinsOverFileToken(t *testing.T) {
 			envCheck := "if c." + resolveEnvVarField(tc.envVar) + ` != ""`
 			tokenCheck := `if c.AccessToken != ""`
 
-			require.Contains(t, content, envCheck,
-				"AuthHeader must check the env-var field for type %q", tc.authType)
-			require.Contains(t, content, tokenCheck,
-				"AuthHeader must still check AccessToken for type %q", tc.authType)
-
-			authHeaderStart := strings.Index(content, "func (c *Config) AuthHeader() string {")
-			require.NotEqual(t, -1, authHeaderStart, "AuthHeader function must be emitted")
-			body := content[authHeaderStart:]
-			// Bound the search to AuthHeader's body. Skip the first byte so
-			// the "next func" lookup finds the func AFTER AuthHeader, not
-			// AuthHeader's own opener.
-			if next := strings.Index(body[1:], "\nfunc "); next != -1 {
-				body = body[:next+1]
-			}
+			require.Contains(t, content, envCheck)
+			require.Contains(t, content, tokenCheck)
 
+			body := authHeaderBody(t, content)
 			envIdx := strings.Index(body, envCheck)
 			tokenIdx := strings.Index(body, tokenCheck)
-			require.NotEqual(t, -1, envIdx)
-			require.NotEqual(t, -1, tokenIdx)
 			assert.Less(t, envIdx, tokenIdx,
-				"env-var check must appear BEFORE AccessToken check in AuthHeader for type %q", tc.authType)
+				"env-var check must appear BEFORE AccessToken check for type %q", tc.authType)
 		})
 	}
 }
+
+// authHeaderBody slices out just the AuthHeader function body so precedence
+// assertions can't be tricked by a matching pattern in unrelated code
+// further down the file.
+func authHeaderBody(t *testing.T, content string) string {
+	t.Helper()
+	start := strings.Index(content, "func (c *Config) AuthHeader() string {")
+	require.NotEqual(t, -1, start, "AuthHeader function must be emitted")
+	body := content[start:]
+	if next := strings.Index(body[1:], "\nfunc "); next != -1 {
+		body = body[:next+1]
+	}
+	return body
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 70eb4cf5..518fc78d 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -1588,16 +1588,23 @@ func (g *Generator) renderAuthFiles() error {
 	if !g.shouldEmitAuth() {
 		return nil
 	}
-	// Render auth command - use full OAuth2 template when authorization URL is present,
-	// browser cookie template for cookie-auth APIs, otherwise simple token-management template
+	// Render auth command. Template selection priority:
+	//   1. OAuth2 client_credentials (server-to-server, no user redirect)
+	//   2. OAuth2 authorization_code (3-legged, AuthorizationURL non-empty)
+	//   3. Browser-cookie / composed / persisted-query
+	//   4. Simple token-management (catch-all)
 	authPath := filepath.Join("internal", "cli", "auth.go")
 	authTmpl := "auth_simple.go.tmpl"
-	if g.Spec.Auth.AuthorizationURL != "" {
+	switch {
+	case g.Spec.Auth.EffectiveOAuth2Grant() == spec.OAuth2GrantClientCredentials && g.Spec.Auth.TokenURL != "":
+		authTmpl = "auth_client_credentials.go.tmpl"
+	case g.Spec.Auth.AuthorizationURL != "":
 		authTmpl = "auth.go.tmpl"
-	} else if g.Spec.Auth.Type == "cookie" || g.Spec.Auth.Type == "composed" || g.hasTrafficAnalysisHint("graphql_persisted_query") {
-		// Select the browser-aware auth template for browser-cookie auth or a
-		// persisted-query registry, even for auth.type:none. Query refresh flows
-		// need temporary browser capture support, not a resident browser transport.
+	case g.Spec.Auth.Type == "cookie" || g.Spec.Auth.Type == "composed" || g.hasTrafficAnalysisHint("graphql_persisted_query"):
+		// Browser-aware auth template for browser-cookie auth or a
+		// persisted-query registry, even for auth.type:none. Query refresh
+		// flows need temporary browser capture support, not a resident
+		// browser transport.
 		authTmpl = "auth_browser.go.tmpl"
 	}
 	authData := &authTemplateData{
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 17432509..9c2df7c8 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -567,6 +567,200 @@ func TestGeneratedOutput_READMEBearerTokenMCPSetup(t *testing.T) {
 	assert.NotContains(t, content, "bearer-pp-cli auth login\n\nclaude mcp add bearer bearer-pp-mcp")
 }
 
+func TestGenerateOAuth2ClientCredentialsAuthTemplate(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "ccgrant",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:        "bearer_token",
+			Header:      "Authorization",
+			Format:      "Bearer {token}",
+			OAuth2Grant: spec.OAuth2GrantClientCredentials,
+			TokenURL:    "https://api.example.com/oauth/token",
+			EnvVars:     []string{"CCGRANT_API_KEY", "CCGRANT_SECRET_KEY"},
+		},
+		Config: spec.ConfigSpec{Format: "toml", Path: "~/.config/ccgrant-pp-cli/config.toml"},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/items"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	authBytes, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "auth.go"))
+	require.NoError(t, err)
+	body := string(authBytes)
+
+	// New login command with the right token URL hardcoded.
+	assert.Contains(t, body, `newAuthLoginCmd(flags)`,
+		"client_credentials template emits a login command")
+	assert.Contains(t, body, `"https://api.example.com/oauth/token"`,
+		"login command POSTs to the spec's TokenURL")
+	assert.Contains(t, body, `"grant_type":    {"client_credentials"}`,
+		"login command uses client_credentials grant")
+	assert.Contains(t, body, `os.Getenv("CCGRANT_API_KEY")`,
+		"client-id defaults to first env var")
+	assert.Contains(t, body, `os.Getenv("CCGRANT_SECRET_KEY")`,
+		"client-secret defaults to second env var")
+	// Verify-env short-circuit is present so the side-effect classifier
+	// doesn't false-positive during shipcheck.
+	assert.Contains(t, body, `cliutil.IsVerifyEnv()`,
+		"login command short-circuits under PRINTING_PRESS_VERIFY=1")
+	assert.Contains(t, body, `Annotations: map[string]string{"mcp:hidden": "true"}`,
+		"auth login is hidden from MCP")
+}
+
+func TestGenerateOAuth2AuthorizationCodeRegression(t *testing.T) {
+	t.Parallel()
+
+	// Spec with OAuth2 authorization_code (the existing 3-legged flow):
+	// AuthorizationURL non-empty, OAuth2Grant unset (defaults to
+	// authorization_code). Must continue to select auth.go.tmpl, NOT
+	// auth_client_credentials.go.tmpl.
+	apiSpec := &spec.APISpec{
+		Name:    "ac3legged",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:             "bearer_token",
+			Header:           "Authorization",
+			Format:           "Bearer {token}",
+			AuthorizationURL: "https://api.example.com/oauth/authorize",
+			TokenURL:         "https://api.example.com/oauth/token",
+			EnvVars:          []string{"AC3LEGGED_TOKEN"},
+		},
+		Config: spec.ConfigSpec{Format: "toml", Path: "~/.config/ac3legged-pp-cli/config.toml"},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/items"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	authBytes, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "auth.go"))
+	require.NoError(t, err)
+	body := string(authBytes)
+
+	// auth.go.tmpl emits the authorization_code grant body — should be
+	// present, NOT the client_credentials body.
+	assert.Contains(t, body, `"grant_type":   {"authorization_code"}`,
+		"authorization_code spec keeps the existing 3-legged template")
+	assert.NotContains(t, body, `"grant_type":    {"client_credentials"}`,
+		"authorization_code spec must NOT pick the client_credentials template")
+}
+
+func TestGenerateOAuth2ClientCredentialsClientRefresh(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "ccclient",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:        "bearer_token",
+			Header:      "Authorization",
+			Format:      "Bearer {token}",
+			OAuth2Grant: spec.OAuth2GrantClientCredentials,
+			TokenURL:    "https://api.example.com/oauth/token",
+			EnvVars:     []string{"CCCLIENT_API_KEY", "CCCLIENT_SECRET_KEY"},
+		},
+		Config: spec.ConfigSpec{Format: "toml", Path: "~/.config/ccclient-pp-cli/config.toml"},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/items"}},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	clientBytes, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+	require.NoError(t, err)
+	body := string(clientBytes)
+
+	assert.Contains(t, body, "func needsClientCredentialsMint",
+		"client_credentials spec emits the safety-window helper")
+	assert.Contains(t, body, "func resolveClientCredentials",
+		"client_credentials spec emits the env-var-fallback resolver")
+	assert.Contains(t, body, "func (c *Client) mintClientCredentials",
+		"client_credentials spec emits the proactive-mint helper")
+	assert.Contains(t, body, `"grant_type":    {"client_credentials"}`,
+		"refresh path uses client_credentials grant, not refresh_token")
+	assert.Contains(t, body, `"https://api.example.com/oauth/token"`,
+		"mint POSTs to the spec's TokenURL")
+	assert.Contains(t, body, "time.Until(cfg.TokenExpiry) < 60*time.Second",
+		"60-second proactive refresh window")
+	assert.Contains(t, body, `os.Getenv("CCCLIENT_API_KEY")`,
+		"client-id resolver falls back to first env var")
+	assert.Contains(t, body, `os.Getenv("CCCLIENT_SECRET_KEY")`,
+		"client-secret resolver falls back to second env var")
+	assert.Contains(t, body, "ccMu sync.Mutex",
+		"client_credentials spec emits a mutex to serialize concurrent mints")
+	assert.Contains(t, body, "c.ccMu.Lock()",
+		"authHeader takes the mint mutex before re-checking the window")
+}
+
+func TestGenerateOAuth2AuthorizationCodeClientRefreshUnchanged(t *testing.T) {
+	t.Parallel()
+
+	// Spec with the existing 3-legged flow (AuthorizationURL non-empty,
+	// no OAuth2Grant). The client.go template's refresh path must keep
+	// using the refresh_token grant — no regression.
+	apiSpec := &spec.APISpec{
+		Name:    "acclient",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:             "bearer_token",
+			Header:           "Authorization",
+			Format:           "Bearer {token}",
+			AuthorizationURL: "https://api.example.com/oauth/authorize",
+			TokenURL:         "https://api.example.com/oauth/token",
+			EnvVars:          []string{"ACCLIENT_TOKEN"},
+		},
+		Config: spec.ConfigSpec{Format: "toml", Path: "~/.config/acclient-pp-cli/config.toml"},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/items"}},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	clientBytes, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+	require.NoError(t, err)
+	body := string(clientBytes)
+
+	assert.Contains(t, body, `"grant_type":    {"refresh_token"}`,
+		"authorization_code spec keeps refresh_token grant in refresh path")
+	assert.NotContains(t, body, "func mintClientCredentials",
+		"authorization_code spec must NOT emit client_credentials helpers")
+	assert.NotContains(t, body, "func needsClientCredentialsMint",
+		"authorization_code spec must NOT emit safety-window helper")
+	assert.NotContains(t, body, "ccMu sync.Mutex",
+		"authorization_code spec must NOT emit the client_credentials mint mutex")
+}
+
 func TestGenerateAPIKeyAuthFormatSupportsTokenPlaceholder(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/generator/templates/auth_client_credentials.go.tmpl b/internal/generator/templates/auth_client_credentials.go.tmpl
new file mode 100644
index 00000000..d3bdd719
--- /dev/null
+++ b/internal/generator/templates/auth_client_credentials.go.tmpl
@@ -0,0 +1,248 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"strings"
+	"time"
+
+	"{{modulePath}}/internal/cliutil"
+	"{{modulePath}}/internal/config"
+	"github.com/spf13/cobra"
+)
+
+// OAuth2 client_credentials grant: 2-legged server-to-server flow.
+// POST to TokenURL with form-encoded client_id/client_secret. No user
+// redirect, no refresh_token. The client re-mints when within 60s of expiry.
+
+func newAuthCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "auth",
+{{- if .Auth.EnvVars}}
+		Short: "Manage {{index .Auth.EnvVars 0}} credentials (login, status, logout)",
+{{- else}}
+		Short: "Manage OAuth2 credentials (login, status, logout)",
+{{- end}}
+	}
+
+	cmd.AddCommand(newAuthLoginCmd(flags))
+	cmd.AddCommand(newAuthStatusCmd(flags))
+	cmd.AddCommand(newAuthSetTokenCmd(flags))
+	cmd.AddCommand(newAuthLogoutCmd(flags))
+
+	return cmd
+}
+
+func newAuthLoginCmd(flags *rootFlags) *cobra.Command {
+	var (
+		clientID     string
+		clientSecret string
+	)
+
+	cmd := &cobra.Command{
+		Use:   "login",
+		Short: "Mint an OAuth2 bearer token via the client_credentials grant",
+		Long: strings.TrimSpace(` + "`" + `
+Mint an OAuth2 bearer token via POST {{.Auth.TokenURL}} with
+grant_type=client_credentials and cache it on disk. Subsequent commands
+auto-refresh the token before expiry.
+
+Credentials default to {{if .Auth.EnvVars}}{{index .Auth.EnvVars 0}} (Client ID){{if gt (len .Auth.EnvVars) 1}} and {{index .Auth.EnvVars 1}} (Client Secret){{end}}{{else}}your project's Client ID and Client Secret{{end}} environment variables.
+` + "`" + `),
+		Example: strings.Trim(` + "`" + `
+  # Use env vars
+  {{.Name}}-pp-cli auth login
+
+  # Explicit credentials
+  {{.Name}}-pp-cli auth login --client-id <id> --client-secret <secret>
+` + "`" + `, "\n"),
+		Annotations: map[string]string{"mcp:hidden": "true"},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if cliutil.IsVerifyEnv() {
+				fmt.Fprintln(cmd.OutOrStdout(), "would mint token via OAuth2 client_credentials")
+				return nil
+			}
+
+			if clientID == "" {
+{{- if .Auth.EnvVars}}
+				clientID = os.Getenv("{{index .Auth.EnvVars 0}}")
+{{- end}}
+			}
+			if clientSecret == "" {
+{{- if gt (len .Auth.EnvVars) 1}}
+				clientSecret = os.Getenv("{{index .Auth.EnvVars 1}}")
+{{- end}}
+			}
+			if clientID == "" || clientSecret == "" {
+{{- if gt (len .Auth.EnvVars) 1}}
+				return authErr(fmt.Errorf("client ID and secret required (set --client-id/--client-secret or {{index .Auth.EnvVars 0}}/{{index .Auth.EnvVars 1}})"))
+{{- else}}
+				return authErr(fmt.Errorf("client ID and secret required (set --client-id and --client-secret)"))
+{{- end}}
+			}
+
+			tok, err := mintClientCredentialsToken(http.DefaultClient, "{{.Auth.TokenURL}}", clientID, clientSecret)
+			if err != nil {
+				return authErr(err)
+			}
+
+			cfg, err := config.Load(flags.configPath)
+			if err != nil {
+				return configErr(err)
+			}
+			cfg.AuthHeaderVal = ""
+			expiry := time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second)
+			if err := cfg.SaveTokens(clientID, clientSecret, tok.AccessToken, "", expiry); err != nil {
+				return configErr(fmt.Errorf("saving token: %w", err))
+			}
+
+			fmt.Fprintf(cmd.OutOrStdout(), "Logged in. Token expires %s (in %ds).\n",
+				expiry.Format(time.RFC3339), tok.ExpiresIn)
+			return nil
+		},
+	}
+
+{{- if .Auth.EnvVars}}
+	cmd.Flags().StringVar(&clientID, "client-id", "", "OAuth2 Client ID; defaults to ${{index .Auth.EnvVars 0}}")
+{{- if gt (len .Auth.EnvVars) 1}}
+	cmd.Flags().StringVar(&clientSecret, "client-secret", "", "OAuth2 Client Secret; defaults to ${{index .Auth.EnvVars 1}}")
+{{- else}}
+	cmd.Flags().StringVar(&clientSecret, "client-secret", "", "OAuth2 Client Secret")
+{{- end}}
+{{- else}}
+	cmd.Flags().StringVar(&clientID, "client-id", "", "OAuth2 Client ID")
+	cmd.Flags().StringVar(&clientSecret, "client-secret", "", "OAuth2 Client Secret")
+{{- end}}
+
+	return cmd
+}
+
+type tokenResponse struct {
+	AccessToken string ` + "`" + `json:"access_token"` + "`" + `
+	ExpiresIn   int    ` + "`" + `json:"expires_in"` + "`" + `
+}
+
+// mintClientCredentialsToken POSTs grant_type=client_credentials to the
+// token endpoint and returns the parsed token response.
+func mintClientCredentialsToken(httpClient *http.Client, tokenURL, clientID, clientSecret string) (*tokenResponse, error) {
+	form := url.Values{
+		"grant_type":    {"client_credentials"},
+		"client_id":     {clientID},
+		"client_secret": {clientSecret},
+	}
+	req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
+	if err != nil {
+		return nil, fmt.Errorf("building token request: %w", err)
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+	resp, err := httpClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("calling token endpoint: %w", err)
+	}
+	defer resp.Body.Close()
+
+	body, _ := io.ReadAll(resp.Body)
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("token endpoint returned HTTP %d: %s", resp.StatusCode, cliutil.SanitizeErrorBody(string(body)))
+	}
+	var tok tokenResponse
+	if err := json.Unmarshal(body, &tok); err != nil {
+		return nil, fmt.Errorf("parsing token response: %w", err)
+	}
+	if tok.AccessToken == "" {
+		return nil, fmt.Errorf("token response missing access_token")
+	}
+	return &tok, nil
+}
+
+func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
+	return &cobra.Command{
+		Use:     "status",
+		Short:   "Show authentication status",
+		Example: "  {{.Name}}-pp-cli auth status",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, err := config.Load(flags.configPath)
+			if err != nil {
+				return configErr(err)
+			}
+
+			w := cmd.OutOrStdout()
+			header := cfg.AuthHeader()
+			if header == "" {
+				fmt.Fprintln(w, red("Not authenticated"))
+				fmt.Fprintln(w, "")
+				fmt.Fprintln(w, "Mint a token:")
+{{- if .Auth.EnvVars}}
+				fmt.Fprintln(w, "  export {{index .Auth.EnvVars 0}}=\"<client-id>\"")
+{{- if gt (len .Auth.EnvVars) 1}}
+				fmt.Fprintln(w, "  export {{index .Auth.EnvVars 1}}=\"<client-secret>\"")
+{{- end}}
+{{- end}}
+				fmt.Fprintf(w, "  {{.Name}}-pp-cli auth login\n")
+				return authErr(fmt.Errorf("no credentials configured"))
+			}
+
+			fmt.Fprintln(w, green("Authenticated"))
+			fmt.Fprintf(w, "  Source: %s\n", cfg.AuthSource)
+			fmt.Fprintf(w, "  Config: %s\n", cfg.Path)
+			if !cfg.TokenExpiry.IsZero() {
+				fmt.Fprintf(w, "  Expires: %s\n", cfg.TokenExpiry.Format(time.RFC3339))
+			}
+			return nil
+		},
+	}
+}
+
+func newAuthSetTokenCmd(flags *rootFlags) *cobra.Command {
+	return &cobra.Command{
+		Use:     "set-token <token>",
+		Short:   "Save an API token to the config file (override the OAuth flow)",
+		Example: "  {{.Name}}-pp-cli auth set-token <bearer-jwt>",
+		Args:    cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, err := config.Load(flags.configPath)
+			if err != nil {
+				return configErr(err)
+			}
+			cfg.AuthHeaderVal = ""
+			if err := cfg.SaveTokens("", "", args[0], "", cfg.TokenExpiry); err != nil {
+				return configErr(fmt.Errorf("saving token: %w", err))
+			}
+			fmt.Fprintf(cmd.OutOrStdout(), "Token saved to %s\n", cfg.Path)
+			return nil
+		},
+	}
+}
+
+func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
+	return &cobra.Command{
+		Use:     "logout",
+		Short:   "Clear stored credentials",
+		Example: "  {{.Name}}-pp-cli auth logout",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, err := config.Load(flags.configPath)
+			if err != nil {
+				return configErr(err)
+			}
+			if err := cfg.ClearTokens(); err != nil {
+				return configErr(fmt.Errorf("clearing tokens: %w", err))
+			}
+{{- range .Auth.EnvVars}}
+			if os.Getenv("{{.}}") != "" {
+				fmt.Fprintf(cmd.OutOrStdout(), "Config cleared. Note: {{.}} env var is still set.\n")
+				return nil
+			}
+{{- end}}
+			fmt.Fprintln(cmd.OutOrStdout(), "Logged out. Credentials cleared.")
+			return nil
+		},
+	}
+}
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 562f2e2f..399540c1 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -19,6 +19,9 @@ import (
 	"sort"
 {{- end}}
 	"strings"
+{{- if eq .Auth.EffectiveOAuth2Grant "client_credentials"}}
+	"sync"
+{{- end}}
 	"time"
 
 {{- if .UsesBrowserHTTPTransport}}
@@ -36,6 +39,12 @@ type Client struct {
 	NoCache    bool
 	cacheDir   string
 	limiter    *cliutil.AdaptiveLimiter
+{{- if eq .Auth.EffectiveOAuth2Grant "client_credentials"}}
+	// ccMu serializes OAuth2 client_credentials token mints so concurrent
+	// API calls inside the 60s pre-expiry window don't all dial the token
+	// endpoint and race on Config field writes / file persistence.
+	ccMu sync.Mutex
+{{- end}}
 {{- if eq .Auth.Type "session_handshake"}}
 	Session    *SessionManager
 {{- end}}
@@ -738,14 +747,106 @@ func (c *Client) authHeader() (string, error) {
 	if c.Config == nil {
 		return "", nil
 	}
+{{- if eq .Auth.EffectiveOAuth2Grant "client_credentials"}}
+	// 60s window avoids in-flight requests racing the expiry boundary.
+	// Double-checked lock so only one goroutine mints under contention.
+	if needsClientCredentialsMint(c.Config) {
+		c.ccMu.Lock()
+		if needsClientCredentialsMint(c.Config) {
+			clientID, clientSecret := resolveClientCredentials(c.Config)
+			if clientID != "" && clientSecret != "" {
+				if err := c.mintClientCredentials(clientID, clientSecret); err != nil {
+					c.ccMu.Unlock()
+					return "", err
+				}
+			}
+		}
+		c.ccMu.Unlock()
+	}
+{{- else}}
 	if c.Config.AccessToken != "" && !c.Config.TokenExpiry.IsZero() && time.Now().After(c.Config.TokenExpiry) && c.Config.RefreshToken != "" {
 		if err := c.refreshAccessToken(); err != nil {
 			return "", err
 		}
 	}
+{{- end}}
 	return c.Config.AuthHeader(), nil
 }
 
+{{- if eq .Auth.EffectiveOAuth2Grant "client_credentials"}}
+
+func needsClientCredentialsMint(cfg *config.Config) bool {
+	if cfg.AccessToken == "" {
+		return true
+	}
+	if cfg.TokenExpiry.IsZero() {
+		return false
+	}
+	return time.Until(cfg.TokenExpiry) < 60*time.Second
+}
+
+// resolveClientCredentials prefers what was passed to `auth login` (cached
+// in config) and falls back to environment variables so first-time users
+// can ship without a separate login step.
+func resolveClientCredentials(cfg *config.Config) (string, string) {
+	id := cfg.ClientID
+	secret := cfg.ClientSecret
+{{- if .Auth.EnvVars}}
+	if id == "" {
+		id = os.Getenv("{{index .Auth.EnvVars 0}}")
+	}
+{{- if gt (len .Auth.EnvVars) 1}}
+	if secret == "" {
+		secret = os.Getenv("{{index .Auth.EnvVars 1}}")
+	}
+{{- end}}
+{{- end}}
+	return id, secret
+}
+
+func (c *Client) mintClientCredentials(clientID, clientSecret string) error {
+	tokenURL := "{{.Auth.TokenURL}}"
+	if tokenURL == "" {
+		return nil
+	}
+	form := url.Values{
+		"grant_type":    {"client_credentials"},
+		"client_id":     {clientID},
+		"client_secret": {clientSecret},
+	}
+	req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
+	if err != nil {
+		return fmt.Errorf("building token request: %w", err)
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return fmt.Errorf("calling token endpoint: %w", err)
+	}
+	defer resp.Body.Close()
+	body, _ := io.ReadAll(resp.Body)
+	if resp.StatusCode >= 400 {
+		return fmt.Errorf("OAuth client_credentials mint: HTTP %d: %s", resp.StatusCode, cliutil.SanitizeErrorBody(string(body)))
+	}
+	var tokenResp struct {
+		AccessToken string `json:"access_token"`
+		ExpiresIn   int    `json:"expires_in"`
+	}
+	if err := json.Unmarshal(body, &tokenResp); err != nil {
+		return fmt.Errorf("parsing token response: %w", err)
+	}
+	if tokenResp.AccessToken == "" {
+		return fmt.Errorf("OAuth client_credentials mint: response missing access_token")
+	}
+	expiry := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
+	c.Config.AuthHeaderVal = "" // force AuthHeader() to use the new AccessToken path
+	if err := c.Config.SaveTokens(clientID, clientSecret, tokenResp.AccessToken, "", expiry); err != nil {
+		return fmt.Errorf("saving minted token: %w", err)
+	}
+	return nil
+}
+{{- end}}
+
 func (c *Client) refreshAccessToken() error {
 	if c.Config == nil {
 		return nil
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index 908a1b1a..1dfcd814 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -146,6 +146,33 @@ func (c *Config) AuthHeader() string {
 	return ""
 	{{- end}}
 {{- else if eq .Auth.Type "bearer_token"}}
+{{- if eq .Auth.EffectiveOAuth2Grant "client_credentials"}}
+	// Under OAuth2 client_credentials the env var is the Client ID, not a
+	// usable bearer; the minted AccessToken must win.
+	if c.AccessToken != "" {
+		c.AuthSource = "oauth2"
+		{{- if .Auth.Format}}
+		return applyAuthFormat("{{.Auth.Format}}", map[string]string{"access_token": c.AccessToken, "token": c.AccessToken})
+		{{- else}}
+		return "Bearer " + c.AccessToken
+		{{- end}}
+	}
+	{{- if gt (len .Auth.EnvVars) 0}}
+	if c.{{resolveEnvVarField (index .Auth.EnvVars 0)}} != "" {
+		c.AuthSource = "env:{{index .Auth.EnvVars 0}}"
+		{{- if .Auth.Format}}
+		return applyAuthFormat("{{.Auth.Format}}", map[string]string{
+			"{{envVarPlaceholder (index .Auth.EnvVars 0)}}": c.{{resolveEnvVarField (index .Auth.EnvVars 0)}},
+			"{{index .Auth.EnvVars 0}}": c.{{resolveEnvVarField (index .Auth.EnvVars 0)}},
+			"token": c.{{resolveEnvVarField (index .Auth.EnvVars 0)}},
+		})
+		{{- else}}
+		return "Bearer " + c.{{resolveEnvVarField (index .Auth.EnvVars 0)}}
+		{{- end}}
+	}
+	{{- end}}
+	return ""
+{{- else}}
 	// Env-var token wins over file-stored AccessToken (env > config convention).
 	{{- if gt (len .Auth.EnvVars) 0}}
 	if c.{{resolveEnvVarField (index .Auth.EnvVars 0)}} != "" {
@@ -170,6 +197,7 @@ func (c *Config) AuthHeader() string {
 		{{- end}}
 	}
 	return ""
+{{- end}}
 {{- else if eq .Auth.Type "cookie"}}
 	// Env-var token wins over file-stored AccessToken (env > config convention).
 	{{- if gt (len .Auth.EnvVars) 0}}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 4e21a06d..f893ae51 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -379,7 +379,20 @@ func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
 		auth.Type = "bearer_token"
 		auth.Header = "Authorization"
 		if scheme.Flows != nil {
-			if ac := scheme.Flows.AuthorizationCode; ac != nil {
+			// Prefer client_credentials when both flows are declared.
+			// Server-to-server is the more common shape for printed CLIs
+			// (which run in CI/scripts, not interactive browsers); the spec
+			// author can override post-import by setting OAuth2Grant
+			// explicitly. AuthorizationURL stays empty for the cc flow
+			// (no user redirect), which is the correct shape.
+			if cc := scheme.Flows.ClientCredentials; cc != nil && strings.TrimSpace(cc.TokenURL) != "" {
+				auth.OAuth2Grant = spec.OAuth2GrantClientCredentials
+				auth.TokenURL = cc.TokenURL
+				for scope := range cc.Scopes {
+					auth.Scopes = append(auth.Scopes, scope)
+				}
+				sort.Strings(auth.Scopes)
+			} else if ac := scheme.Flows.AuthorizationCode; ac != nil {
 				auth.AuthorizationURL = ac.AuthorizationURL
 				auth.TokenURL = ac.TokenURL
 				for scope := range ac.Scopes {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index a8263386..dd382796 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -158,6 +158,137 @@ func TestParseGmailOAuth2(t *testing.T) {
 	assert.Equal(t, "https://accounts.google.com/o/oauth2/auth", parsed.Auth.AuthorizationURL)
 	assert.Equal(t, "https://accounts.google.com/o/oauth2/token", parsed.Auth.TokenURL)
 	assert.NotEmpty(t, parsed.Auth.Scopes)
+	// gmail uses authorization_code flow; OAuth2Grant stays empty so the
+	// EffectiveOAuth2Grant() default of "authorization_code" applies.
+	assert.Equal(t, "", parsed.Auth.OAuth2Grant)
+}
+
+func TestParseOAuth2ClientCredentialsFlow(t *testing.T) {
+	t.Parallel()
+
+	specBytes := []byte(`openapi: "3.0.3"
+info:
+  title: Auth0Mgmt
+  version: "1.0"
+servers:
+  - url: https://example.auth0.com
+components:
+  securitySchemes:
+    OAuth2:
+      type: oauth2
+      flows:
+        clientCredentials:
+          tokenUrl: https://example.auth0.com/oauth/token
+          scopes:
+            read:users: Read user profiles
+            write:users: Manage users
+paths:
+  /api/v2/users:
+    get:
+      operationId: list users
+      security:
+        - OAuth2: []
+      responses: {"200": {description: ok}}
+`)
+
+	parsed, err := Parse(specBytes)
+	require.NoError(t, err)
+
+	assert.Equal(t, "bearer_token", parsed.Auth.Type, "parser keeps bearer_token shape; grant lives on OAuth2Grant")
+	assert.Equal(t, "client_credentials", parsed.Auth.OAuth2Grant)
+	assert.Equal(t, "https://example.auth0.com/oauth/token", parsed.Auth.TokenURL)
+	assert.Empty(t, parsed.Auth.AuthorizationURL, "client_credentials flow has no user redirect")
+	assert.Equal(t, []string{"read:users", "write:users"}, parsed.Auth.Scopes)
+}
+
+func TestParseOAuth2BothFlowsPrefersClientCredentials(t *testing.T) {
+	t.Parallel()
+
+	// When a single OAuth2 scheme declares both authorizationCode and
+	// clientCredentials flows, the parser prefers clientCredentials —
+	// server-to-server is the more common shape for printed CLIs (which
+	// run in CI/scripts, not interactive browsers). Spec authors override
+	// post-import by setting OAuth2Grant explicitly.
+	specBytes := []byte(`openapi: "3.0.3"
+info:
+  title: Hybrid
+  version: "1.0"
+servers:
+  - url: https://example.com
+components:
+  securitySchemes:
+    OAuth2:
+      type: oauth2
+      flows:
+        authorizationCode:
+          authorizationUrl: https://example.com/oauth/authorize
+          tokenUrl: https://example.com/oauth/token-ac
+          scopes:
+            user: User access
+        clientCredentials:
+          tokenUrl: https://example.com/oauth/token-cc
+          scopes:
+            admin: Admin access
+paths:
+  /v1/things:
+    get:
+      operationId: list things
+      security:
+        - OAuth2: []
+      responses: {"200": {description: ok}}
+`)
+
+	parsed, err := Parse(specBytes)
+	require.NoError(t, err)
+
+	assert.Equal(t, "client_credentials", parsed.Auth.OAuth2Grant)
+	assert.Equal(t, "https://example.com/oauth/token-cc", parsed.Auth.TokenURL,
+		"clientCredentials tokenUrl wins, not authorizationCode's")
+	assert.Empty(t, parsed.Auth.AuthorizationURL, "no user redirect for the cc flow")
+	assert.Equal(t, []string{"admin"}, parsed.Auth.Scopes,
+		"clientCredentials scopes win, not authorizationCode's")
+}
+
+func TestParseOAuth2ClientCredentialsMissingTokenURLSkipsBranch(t *testing.T) {
+	t.Parallel()
+
+	// Malformed spec: clientCredentials block exists but has no tokenUrl.
+	// Parser should skip the cc branch and fall through to the next flow
+	// (or leave fields empty if no other flows exist), not crash.
+	specBytes := []byte(`openapi: "3.0.3"
+info:
+  title: Malformed
+  version: "1.0"
+servers:
+  - url: https://example.com
+components:
+  securitySchemes:
+    OAuth2:
+      type: oauth2
+      flows:
+        clientCredentials:
+          tokenUrl: ""
+          scopes: {}
+        authorizationCode:
+          authorizationUrl: https://example.com/auth
+          tokenUrl: https://example.com/token
+          scopes: {}
+paths:
+  /v1/foo:
+    get:
+      operationId: foo
+      security:
+        - OAuth2: []
+      responses: {"200": {description: ok}}
+`)
+
+	parsed, err := Parse(specBytes)
+	require.NoError(t, err)
+
+	// Falls through to authorizationCode since cc had no tokenUrl.
+	assert.Equal(t, "", parsed.Auth.OAuth2Grant)
+	assert.Equal(t, "https://example.com/token", parsed.Auth.TokenURL)
+	assert.Equal(t, "https://example.com/auth", parsed.Auth.AuthorizationURL)
 }
 
 func TestBearerSchemeNameCanSpecializeEnvVar(t *testing.T) {
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 62db7e05..1751e164 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -366,6 +366,43 @@ type AuthConfig struct {
 	TokenParamIn       string `yaml:"token_param_in,omitempty" json:"token_param_in,omitempty"`             // "query" or "header"; default "query"
 	InvalidateOnStatus []int  `yaml:"invalidate_on_status,omitempty" json:"invalidate_on_status,omitempty"` // HTTP status codes that should invalidate the cached token and re-bootstrap (e.g. [401, 403])
 	SessionTTLHours    int    `yaml:"session_ttl_hours,omitempty" json:"session_ttl_hours,omitempty"`       // how long to trust a cached session (default 24)
+
+	// OAuth2Grant selects the OAuth2 sub-flow when Type=="oauth2". Defaults
+	// to authorization_code; ignored for non-oauth2 types. Read via
+	// EffectiveOAuth2Grant() so the default lives in one place.
+	OAuth2Grant string `yaml:"oauth2_grant,omitempty" json:"oauth2_grant,omitempty"`
+}
+
+// OAuth2GrantAuthorizationCode is the 3-legged user-OAuth flow (browser
+// redirect, callback server, code exchange at TokenURL).
+const OAuth2GrantAuthorizationCode = "authorization_code"
+
+// OAuth2GrantClientCredentials is the 2-legged server-to-server flow used
+// by M2M APIs (Auth0 Management, Microsoft Graph daemon apps): POST to
+// TokenURL with form-encoded client_id/client_secret, no user redirect.
+const OAuth2GrantClientCredentials = "client_credentials"
+
+// EffectiveOAuth2Grant returns the configured OAuth2 grant type, defaulting
+// to OAuth2GrantAuthorizationCode when unset.
+func (c AuthConfig) EffectiveOAuth2Grant() string {
+	if strings.TrimSpace(c.OAuth2Grant) == "" {
+		return OAuth2GrantAuthorizationCode
+	}
+	return c.OAuth2Grant
+}
+
+// validateOAuth2Grant ensures OAuth2Grant is empty or one of the supported
+// values. Empty is accepted (treated as the default). Cross-checking against
+// AuthConfig.Type is intentionally skipped: the field is ignored for
+// non-oauth2 types, matching how SessionTTLHours and similar fields behave.
+func validateOAuth2Grant(c AuthConfig) error {
+	switch c.OAuth2Grant {
+	case "", OAuth2GrantAuthorizationCode, OAuth2GrantClientCredentials:
+		return nil
+	default:
+		return fmt.Errorf("auth.oauth2_grant %q is not recognized (valid: %q, %q)",
+			c.OAuth2Grant, OAuth2GrantAuthorizationCode, OAuth2GrantClientCredentials)
+	}
 }
 
 type ConfigSpec struct {
@@ -1085,6 +1122,9 @@ func (s *APISpec) Validate() error {
 	if err := validateThrottling(s.Throttling); err != nil {
 		return err
 	}
+	if err := validateOAuth2Grant(s.Auth); err != nil {
+		return err
+	}
 	if s.ClientPattern == "proxy-envelope" && s.HasResourceBaseURLOverride() {
 		return fmt.Errorf("resource base_url overrides are incompatible with client_pattern=proxy-envelope; the proxy POSTs every request to the spec-level BaseURL, so per-resource overrides would be silently ignored")
 	}
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 137fabf9..57d363cf 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -144,6 +144,64 @@ func TestThrottlingValidate(t *testing.T) {
 	}
 }
 
+func TestOAuth2GrantValidate(t *testing.T) {
+	tests := []struct {
+		name    string
+		cfg     AuthConfig
+		wantErr string
+	}{
+		{name: "empty is valid (defaults to authorization_code)", cfg: AuthConfig{}},
+		{name: "explicit authorization_code is valid", cfg: AuthConfig{OAuth2Grant: OAuth2GrantAuthorizationCode}},
+		{name: "client_credentials is valid", cfg: AuthConfig{OAuth2Grant: OAuth2GrantClientCredentials}},
+		{
+			// Cross-checking against AuthConfig.Type is intentionally skipped
+			// (the field is meaningless for non-oauth2 types but harmless to
+			// declare); validation should accept this combo.
+			name: "set on a non-oauth2 type is accepted (ignored at template time)",
+			cfg:  AuthConfig{Type: "api_key", OAuth2Grant: OAuth2GrantClientCredentials},
+		},
+		{
+			name:    "unknown grant is rejected with valid set in error",
+			cfg:     AuthConfig{OAuth2Grant: "device_code"},
+			wantErr: `auth.oauth2_grant "device_code" is not recognized`,
+		},
+		{
+			name:    "typo (e.g. authorisation) is rejected",
+			cfg:     AuthConfig{OAuth2Grant: "authorisation_code"},
+			wantErr: "not recognized",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := validateOAuth2Grant(tt.cfg)
+			if tt.wantErr == "" {
+				require.NoError(t, err)
+				return
+			}
+			require.Error(t, err)
+			assert.Contains(t, err.Error(), tt.wantErr)
+		})
+	}
+}
+
+func TestEffectiveOAuth2Grant(t *testing.T) {
+	tests := []struct {
+		name string
+		cfg  AuthConfig
+		want string
+	}{
+		{name: "empty defaults to authorization_code", cfg: AuthConfig{}, want: OAuth2GrantAuthorizationCode},
+		{name: "whitespace-only also defaults", cfg: AuthConfig{OAuth2Grant: "   "}, want: OAuth2GrantAuthorizationCode},
+		{name: "explicit authorization_code round-trips", cfg: AuthConfig{OAuth2Grant: OAuth2GrantAuthorizationCode}, want: OAuth2GrantAuthorizationCode},
+		{name: "client_credentials round-trips", cfg: AuthConfig{OAuth2Grant: OAuth2GrantClientCredentials}, want: OAuth2GrantClientCredentials},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.want, tt.cfg.EffectiveOAuth2Grant())
+		})
+	}
+}
+
 func TestVersionPassedThrough(t *testing.T) {
 	base := func(v string) APISpec {
 		return APISpec{
diff --git a/testdata/golden/cases/generate-golden-api-oauth2-cc/artifacts.txt b/testdata/golden/cases/generate-golden-api-oauth2-cc/artifacts.txt
new file mode 100644
index 00000000..935fd009
--- /dev/null
+++ b/testdata/golden/cases/generate-golden-api-oauth2-cc/artifacts.txt
@@ -0,0 +1,26 @@
+# Locks the OAuth2 client_credentials auth shape contract end-to-end.
+# The fixture declares only flows.clientCredentials; every artifact
+# below proves a chokepoint in the auth shape pipeline ran:
+#
+#   parser.go      → AuthConfig.OAuth2Grant=client_credentials, TokenURL set
+#   generator.go   → auth_client_credentials.go.tmpl selected (not auth.go.tmpl)
+#   config.go.tmpl → AuthHeader emits AccessToken-first (the gated branch)
+#   client.go.tmpl → emits needsClientCredentialsMint, mintClientCredentials,
+#                    ccMu sync.Mutex, double-checked lock in authHeader
+#
+# If a future refactor regresses the parser branch, template selection,
+# or the gating, these files either disappear or contain the wrong
+# auth shape and the harness fails with a content diff.
+
+# auth.go is the auth_client_credentials.go.tmpl output: login command
+# does form-encoded POST to TokenURL with grant_type=client_credentials.
+printing-press-oauth2-cc/internal/cli/auth.go
+
+# client.go carries the mint helpers + the mutex + the gated authHeader
+# path. The presence of needsClientCredentialsMint, ccMu, and the
+# double-checked-lock pattern in this file is the load-bearing contract.
+printing-press-oauth2-cc/internal/client/client.go
+
+# config.go's AuthHeader must emit the AccessToken-first gated branch
+# (under client_credentials the env var is the Client ID, not a bearer).
+printing-press-oauth2-cc/internal/config/config.go
diff --git a/testdata/golden/cases/generate-golden-api-oauth2-cc/command.txt b/testdata/golden/cases/generate-golden-api-oauth2-cc/command.txt
new file mode 100644
index 00000000..9d4295b0
--- /dev/null
+++ b/testdata/golden/cases/generate-golden-api-oauth2-cc/command.txt
@@ -0,0 +1,2 @@
+set -euo pipefail
+"$BINARY" generate --spec testdata/golden/fixtures/golden-api-oauth2-cc.yaml --output "$CASE_ACTUAL_DIR/printing-press-oauth2-cc" --force --spec-url file://testdata/golden/fixtures/golden-api-oauth2-cc.yaml --validate=false
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/exit.txt b/testdata/golden/expected/generate-golden-api-oauth2-cc/exit.txt
new file mode 100644
index 00000000..573541ac
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/exit.txt
@@ -0,0 +1 @@
+0
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/cli/auth.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/cli/auth.go
new file mode 100644
index 00000000..acaddf70
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/cli/auth.go
@@ -0,0 +1,218 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"strings"
+	"time"
+
+	"printing-press-oauth2-pp-cli/internal/cliutil"
+	"printing-press-oauth2-pp-cli/internal/config"
+	"github.com/spf13/cobra"
+)
+
+// OAuth2 client_credentials grant: 2-legged server-to-server flow.
+// POST to TokenURL with form-encoded client_id/client_secret. No user
+// redirect, no refresh_token. The client re-mints when within 60s of expiry.
+
+func newAuthCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "auth",
+		Short: "Manage PRINTING_PRESS_OAUTH2_OAUTH2_CC credentials (login, status, logout)",
+	}
+
+	cmd.AddCommand(newAuthLoginCmd(flags))
+	cmd.AddCommand(newAuthStatusCmd(flags))
+	cmd.AddCommand(newAuthSetTokenCmd(flags))
+	cmd.AddCommand(newAuthLogoutCmd(flags))
+
+	return cmd
+}
+
+func newAuthLoginCmd(flags *rootFlags) *cobra.Command {
+	var (
+		clientID     string
+		clientSecret string
+	)
+
+	cmd := &cobra.Command{
+		Use:   "login",
+		Short: "Mint an OAuth2 bearer token via the client_credentials grant",
+		Long: strings.TrimSpace(` + "`" + `
+Mint an OAuth2 bearer token via POST https://api.cc.example/oauth/token with
+grant_type=client_credentials and cache it on disk. Subsequent commands
+auto-refresh the token before expiry.
+
+Credentials default to PRINTING_PRESS_OAUTH2_OAUTH2_CC (Client ID) environment variables.
+` + "`" + `),
+		Example: strings.Trim(` + "`" + `
+  # Use env vars
+  printing-press-oauth2-pp-cli auth login
+
+  # Explicit credentials
+  printing-press-oauth2-pp-cli auth login --client-id <id> --client-secret <secret>
+` + "`" + `, "\n"),
+		Annotations: map[string]string{"mcp:hidden": "true"},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if cliutil.IsVerifyEnv() {
+				fmt.Fprintln(cmd.OutOrStdout(), "would mint token via OAuth2 client_credentials")
+				return nil
+			}
+
+			if clientID == "" {
+				clientID = os.Getenv("PRINTING_PRESS_OAUTH2_OAUTH2_CC")
+			}
+			if clientSecret == "" {
+			}
+			if clientID == "" || clientSecret == "" {
+				return authErr(fmt.Errorf("client ID and secret required (set --client-id and --client-secret)"))
+			}
+
+			tok, err := mintClientCredentialsToken(http.DefaultClient, "https://api.cc.example/oauth/token", clientID, clientSecret)
+			if err != nil {
+				return authErr(err)
+			}
+
+			cfg, err := config.Load(flags.configPath)
+			if err != nil {
+				return configErr(err)
+			}
+			cfg.AuthHeaderVal = ""
+			expiry := time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second)
+			if err := cfg.SaveTokens(clientID, clientSecret, tok.AccessToken, "", expiry); err != nil {
+				return configErr(fmt.Errorf("saving token: %w", err))
+			}
+
+			fmt.Fprintf(cmd.OutOrStdout(), "Logged in. Token expires %s (in %ds).\n",
+				expiry.Format(time.RFC3339), tok.ExpiresIn)
+			return nil
+		},
+	}
+	cmd.Flags().StringVar(&clientID, "client-id", "", "OAuth2 Client ID; defaults to $PRINTING_PRESS_OAUTH2_OAUTH2_CC")
+	cmd.Flags().StringVar(&clientSecret, "client-secret", "", "OAuth2 Client Secret")
+
+	return cmd
+}
+
+type tokenResponse struct {
+	AccessToken string ` + "`" + `json:"access_token"` + "`" + `
+	ExpiresIn   int    ` + "`" + `json:"expires_in"` + "`" + `
+}
+
+// mintClientCredentialsToken POSTs grant_type=client_credentials to the
+// token endpoint and returns the parsed token response.
+func mintClientCredentialsToken(httpClient *http.Client, tokenURL, clientID, clientSecret string) (*tokenResponse, error) {
+	form := url.Values{
+		"grant_type":    {"client_credentials"},
+		"client_id":     {clientID},
+		"client_secret": {clientSecret},
+	}
+	req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
+	if err != nil {
+		return nil, fmt.Errorf("building token request: %w", err)
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+	resp, err := httpClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("calling token endpoint: %w", err)
+	}
+	defer resp.Body.Close()
+
+	body, _ := io.ReadAll(resp.Body)
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("token endpoint returned HTTP %d: %s", resp.StatusCode, cliutil.SanitizeErrorBody(string(body)))
+	}
+	var tok tokenResponse
+	if err := json.Unmarshal(body, &tok); err != nil {
+		return nil, fmt.Errorf("parsing token response: %w", err)
+	}
+	if tok.AccessToken == "" {
+		return nil, fmt.Errorf("token response missing access_token")
+	}
+	return &tok, nil
+}
+
+func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
+	return &cobra.Command{
+		Use:     "status",
+		Short:   "Show authentication status",
+		Example: "  printing-press-oauth2-pp-cli auth status",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, err := config.Load(flags.configPath)
+			if err != nil {
+				return configErr(err)
+			}
+
+			w := cmd.OutOrStdout()
+			header := cfg.AuthHeader()
+			if header == "" {
+				fmt.Fprintln(w, red("Not authenticated"))
+				fmt.Fprintln(w, "")
+				fmt.Fprintln(w, "Mint a token:")
+				fmt.Fprintln(w, "  export PRINTING_PRESS_OAUTH2_OAUTH2_CC=\"<client-id>\"")
+				fmt.Fprintf(w, "  printing-press-oauth2-pp-cli auth login\n")
+				return authErr(fmt.Errorf("no credentials configured"))
+			}
+
+			fmt.Fprintln(w, green("Authenticated"))
+			fmt.Fprintf(w, "  Source: %s\n", cfg.AuthSource)
+			fmt.Fprintf(w, "  Config: %s\n", cfg.Path)
+			if !cfg.TokenExpiry.IsZero() {
+				fmt.Fprintf(w, "  Expires: %s\n", cfg.TokenExpiry.Format(time.RFC3339))
+			}
+			return nil
+		},
+	}
+}
+
+func newAuthSetTokenCmd(flags *rootFlags) *cobra.Command {
+	return &cobra.Command{
+		Use:     "set-token <token>",
+		Short:   "Save an API token to the config file (override the OAuth flow)",
+		Example: "  printing-press-oauth2-pp-cli auth set-token <bearer-jwt>",
+		Args:    cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, err := config.Load(flags.configPath)
+			if err != nil {
+				return configErr(err)
+			}
+			cfg.AuthHeaderVal = ""
+			if err := cfg.SaveTokens("", "", args[0], "", cfg.TokenExpiry); err != nil {
+				return configErr(fmt.Errorf("saving token: %w", err))
+			}
+			fmt.Fprintf(cmd.OutOrStdout(), "Token saved to %s\n", cfg.Path)
+			return nil
+		},
+	}
+}
+
+func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
+	return &cobra.Command{
+		Use:     "logout",
+		Short:   "Clear stored credentials",
+		Example: "  printing-press-oauth2-pp-cli auth logout",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, err := config.Load(flags.configPath)
+			if err != nil {
+				return configErr(err)
+			}
+			if err := cfg.ClearTokens(); err != nil {
+				return configErr(fmt.Errorf("clearing tokens: %w", err))
+			}
+			if os.Getenv("PRINTING_PRESS_OAUTH2_OAUTH2_CC") != "" {
+				fmt.Fprintf(cmd.OutOrStdout(), "Config cleared. Note: PRINTING_PRESS_OAUTH2_OAUTH2_CC env var is still set.\n")
+				return nil
+			}
+			fmt.Fprintln(cmd.OutOrStdout(), "Logged out. Credentials cleared.")
+			return nil
+		},
+	}
+}
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
new file mode 100644
index 00000000..ad221050
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
@@ -0,0 +1,515 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package client
+
+import (
+	"bytes"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"io"
+	"math"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+	"printing-press-oauth2-pp-cli/internal/cliutil"
+	"printing-press-oauth2-pp-cli/internal/config"
+)
+
+type Client struct {
+	BaseURL    string
+	Config     *config.Config
+	HTTPClient *http.Client
+	DryRun     bool
+	NoCache    bool
+	cacheDir   string
+	limiter    *cliutil.AdaptiveLimiter
+	// ccMu serializes OAuth2 client_credentials token mints so concurrent
+	// API calls inside the 60s pre-expiry window don't all dial the token
+	// endpoint and race on Config field writes / file persistence.
+	ccMu sync.Mutex
+}
+
+
+
+// APIError carries HTTP status information for structured exit codes.
+type APIError struct {
+	Method     string
+	Path       string
+	StatusCode int
+	Body       string
+}
+
+func (e *APIError) Error() string {
+	return fmt.Sprintf("%s %s returned HTTP %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
+}
+
+func newHTTPClient(timeout time.Duration, jar http.CookieJar) *http.Client {
+	return &http.Client{Timeout: timeout, Jar: jar}
+}
+
+func New(cfg *config.Config, timeout time.Duration, rateLimit float64) *Client {
+	homeDir, _ := os.UserHomeDir()
+	cacheDir := filepath.Join(homeDir, ".cache", "printing-press-oauth2-pp-cli")
+	httpClient := newHTTPClient(timeout, nil)
+	return &Client{
+		BaseURL:    strings.TrimRight(cfg.BaseURL, "/"),
+		Config:     cfg,
+		HTTPClient: httpClient,
+		cacheDir:   cacheDir,
+		limiter:    cliutil.NewAdaptiveLimiter(rateLimit),
+	}
+}
+
+// RateLimit returns the current effective rate limit in req/s. Returns 0 if disabled.
+func (c *Client) RateLimit() float64 {
+	return c.limiter.Rate()
+}
+
+func (c *Client) Get(path string, params map[string]string) (json.RawMessage, error) {
+	return c.GetWithHeaders(path, params, nil)
+}
+
+func (c *Client) GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {
+	// Check cache for GET requests
+	if !c.NoCache && !c.DryRun && c.cacheDir != "" {
+		if cached, ok := c.readCache(path, params); ok {
+			return cached, nil
+		}
+	}
+	result, _, err := c.do("GET", path, params, nil, headers)
+	if err == nil && !c.NoCache && !c.DryRun && c.cacheDir != "" {
+		c.writeCache(path, params, result)
+	}
+	return result, err
+}
+
+func (c *Client) ProbeGet(path string) (int, error) {
+	_, status, err := c.do("GET", path, nil, nil, nil)
+	return status, err
+}
+
+func (c *Client) cacheKey(path string, params map[string]string) string {
+	key := path
+	for k, v := range params {
+		key += k + "=" + v
+	}
+	h := sha256.Sum256([]byte(key))
+	return hex.EncodeToString(h[:8])
+}
+
+func (c *Client) readCache(path string, params map[string]string) (json.RawMessage, bool) {
+	cacheFile := filepath.Join(c.cacheDir, c.cacheKey(path, params)+".json")
+	info, err := os.Stat(cacheFile)
+	if err != nil || time.Since(info.ModTime()) > 5*time.Minute {
+		return nil, false
+	}
+	data, err := os.ReadFile(cacheFile)
+	if err != nil {
+		return nil, false
+	}
+	return json.RawMessage(data), true
+}
+
+func (c *Client) writeCache(path string, params map[string]string, data json.RawMessage) {
+	os.MkdirAll(c.cacheDir, 0o755)
+	cacheFile := filepath.Join(c.cacheDir, c.cacheKey(path, params)+".json")
+	os.WriteFile(cacheFile, []byte(data), 0o644)
+}
+
+func (c *Client) Post(path string, body any) (json.RawMessage, int, error) {
+	return c.do("POST", path, nil, body, nil)
+}
+
+func (c *Client) PostWithHeaders(path string, body any, headers map[string]string) (json.RawMessage, int, error) {
+	return c.do("POST", path, nil, body, headers)
+}
+
+func (c *Client) Delete(path string) (json.RawMessage, int, error) {
+	return c.do("DELETE", path, nil, nil, nil)
+}
+
+func (c *Client) DeleteWithHeaders(path string, headers map[string]string) (json.RawMessage, int, error) {
+	return c.do("DELETE", path, nil, nil, headers)
+}
+
+func (c *Client) Put(path string, body any) (json.RawMessage, int, error) {
+	return c.do("PUT", path, nil, body, nil)
+}
+
+func (c *Client) PutWithHeaders(path string, body any, headers map[string]string) (json.RawMessage, int, error) {
+	return c.do("PUT", path, nil, body, headers)
+}
+
+func (c *Client) Patch(path string, body any) (json.RawMessage, int, error) {
+	return c.do("PATCH", path, nil, body, nil)
+}
+
+func (c *Client) PatchWithHeaders(path string, body any, headers map[string]string) (json.RawMessage, int, error) {
+	return c.do("PATCH", path, nil, body, headers)
+}
+
+// do executes an HTTP request. headerOverrides, when non-nil, override global
+// RequiredHeaders for this specific request (used for per-endpoint API versioning).
+func (c *Client) do(method, path string, params map[string]string, body any, headerOverrides map[string]string) (json.RawMessage, int, error) {
+	targetURL := c.BaseURL + path
+
+	var bodyBytes []byte
+	if body != nil {
+		b, err := json.Marshal(body)
+		if err != nil {
+			return nil, 0, fmt.Errorf("marshaling body: %w", err)
+		}
+		bodyBytes = b
+	}
+
+	// Resolve auth material before the dry-run branch so --dry-run can preview
+	// exactly what would be sent. Uses only cached credentials; a token that
+	// requires a network refresh will be re-fetched on the live request path,
+	// not during dry-run.
+	authHeader, err := c.authHeader()
+	if err != nil {
+		return nil, 0, err
+	}
+
+	// Build the request for dry-run display or actual execution
+	if c.DryRun {
+		return c.dryRun(method, targetURL, path, params, bodyBytes, headerOverrides, authHeader)
+	}
+
+	const maxRetries = 3
+	var lastErr error
+
+	for attempt := 0; attempt <= maxRetries; attempt++ {
+		// Proactive rate limiting — wait before sending
+		c.limiter.Wait()
+		var bodyReader io.Reader
+		if bodyBytes != nil {
+			bodyReader = strings.NewReader(string(bodyBytes))
+		}
+
+		req, err := http.NewRequest(method, targetURL, bodyReader)
+		if err != nil {
+			return nil, 0, fmt.Errorf("creating request: %w", err)
+		}
+		if bodyBytes != nil {
+			req.Header.Set("Content-Type", "application/json")
+		}
+
+		if params != nil {
+			q := req.URL.Query()
+			for k, v := range params {
+				if v != "" {
+					q.Set(k, v)
+				}
+			}
+			req.URL.RawQuery = q.Encode()
+		}
+
+		if authHeader != "" {
+			req.Header.Set("Authorization", authHeader)
+		}
+		// Per-endpoint header overrides (e.g., different API version per resource)
+		for k, v := range headerOverrides {
+			req.Header.Set(k, v)
+		}
+		req.Header.Set("User-Agent", "printing-press-oauth2-pp-cli/1.0.0")
+
+		resp, err := c.HTTPClient.Do(req)
+		if err != nil {
+			lastErr = fmt.Errorf("%s %s: %w", method, path, err)
+			continue
+		}
+
+		respBody, err := io.ReadAll(resp.Body)
+		resp.Body.Close()
+		if err != nil {
+			return nil, 0, fmt.Errorf("reading response: %w", err)
+		}
+		respBody = sanitizeJSONResponse(respBody)
+
+		// Success
+		if resp.StatusCode < 400 {
+			c.limiter.OnSuccess()
+			return json.RawMessage(respBody), resp.StatusCode, nil
+		}
+
+		apiErr := &APIError{
+			Method:     method,
+			Path:       path,
+			StatusCode: resp.StatusCode,
+			Body:       truncateBody(respBody),
+		}
+
+		// Rate limited - adjust adaptive limiter and retry
+		if resp.StatusCode == 429 && attempt < maxRetries {
+			c.limiter.OnRateLimit()
+			wait := cliutil.RetryAfter(resp)
+			fmt.Fprintf(os.Stderr, "rate limited, waiting %s (attempt %d/%d, rate adjusted to %.1f req/s)\n", wait, attempt+1, maxRetries, c.limiter.Rate())
+			time.Sleep(wait)
+			lastErr = apiErr
+			continue
+		}
+
+		// Server error - retry with backoff
+		if resp.StatusCode >= 500 && attempt < maxRetries {
+			wait := time.Duration(math.Pow(2, float64(attempt))) * time.Second
+			fmt.Fprintf(os.Stderr, "server error %d, retrying in %s (attempt %d/%d)\n", resp.StatusCode, wait, attempt+1, maxRetries)
+			time.Sleep(wait)
+			lastErr = apiErr
+			continue
+		}
+
+		// Client error or retries exhausted - return the error
+		return nil, resp.StatusCode, apiErr
+	}
+
+	return nil, 0, lastErr
+}
+
+// dryRun prints the outgoing request exactly as the live path would send it,
+// using the auth material already resolved in `do()`. Never triggers a network
+// call — the caller is responsible for passing cached auth material only.
+func (c *Client) dryRun(method, targetURL, path string, params map[string]string, body []byte, headerOverrides map[string]string, authHeader string) (json.RawMessage, int, error) {
+	fmt.Fprintf(os.Stderr, "%s %s\n", method, targetURL)
+	queryPrinted := false
+	if params != nil {
+		keys := make([]string, 0, len(params))
+		for k := range params {
+			if params[k] != "" {
+				keys = append(keys, k)
+			}
+		}
+		sort.Strings(keys)
+		for _, k := range keys {
+			sep := "?"
+			if queryPrinted {
+				sep = "&"
+			}
+			fmt.Fprintf(os.Stderr, "  %s%s=%s\n", sep, k, params[k])
+			queryPrinted = true
+		}
+	}
+	_ = queryPrinted
+	if body != nil {
+		var pretty json.RawMessage
+		if json.Unmarshal(body, &pretty) == nil {
+			enc := json.NewEncoder(os.Stderr)
+			enc.SetIndent("  ", "  ")
+			fmt.Fprintf(os.Stderr, "  Body:\n")
+			enc.Encode(pretty)
+		}
+	}
+	if authHeader != "" {
+		fmt.Fprintf(os.Stderr, "  %s: %s\n", "Authorization", maskToken(authHeader))
+	}
+	fmt.Fprintf(os.Stderr, "\n(dry run - no request sent)\n")
+	return json.RawMessage(`{"dry_run": true}`), 0, nil
+}
+
+func (c *Client) ConfiguredTimeout() time.Duration {
+	if c.HTTPClient != nil && c.HTTPClient.Timeout > 0 {
+		return c.HTTPClient.Timeout
+	}
+	return 30 * time.Second
+}
+
+func (c *Client) authHeader() (string, error) {
+	if c.Config == nil {
+		return "", nil
+	}
+	// 60s window avoids in-flight requests racing the expiry boundary.
+	// Double-checked lock so only one goroutine mints under contention.
+	if needsClientCredentialsMint(c.Config) {
+		c.ccMu.Lock()
+		if needsClientCredentialsMint(c.Config) {
+			clientID, clientSecret := resolveClientCredentials(c.Config)
+			if clientID != "" && clientSecret != "" {
+				if err := c.mintClientCredentials(clientID, clientSecret); err != nil {
+					c.ccMu.Unlock()
+					return "", err
+				}
+			}
+		}
+		c.ccMu.Unlock()
+	}
+	return c.Config.AuthHeader(), nil
+}
+
+func needsClientCredentialsMint(cfg *config.Config) bool {
+	if cfg.AccessToken == "" {
+		return true
+	}
+	if cfg.TokenExpiry.IsZero() {
+		return false
+	}
+	return time.Until(cfg.TokenExpiry) < 60*time.Second
+}
+
+// resolveClientCredentials prefers what was passed to `auth login` (cached
+// in config) and falls back to environment variables so first-time users
+// can ship without a separate login step.
+func resolveClientCredentials(cfg *config.Config) (string, string) {
+	id := cfg.ClientID
+	secret := cfg.ClientSecret
+	if id == "" {
+		id = os.Getenv("PRINTING_PRESS_OAUTH2_OAUTH2_CC")
+	}
+	return id, secret
+}
+
+func (c *Client) mintClientCredentials(clientID, clientSecret string) error {
+	tokenURL := "https://api.cc.example/oauth/token"
+	if tokenURL == "" {
+		return nil
+	}
+	form := url.Values{
+		"grant_type":    {"client_credentials"},
+		"client_id":     {clientID},
+		"client_secret": {clientSecret},
+	}
+	req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
+	if err != nil {
+		return fmt.Errorf("building token request: %w", err)
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return fmt.Errorf("calling token endpoint: %w", err)
+	}
+	defer resp.Body.Close()
+	body, _ := io.ReadAll(resp.Body)
+	if resp.StatusCode >= 400 {
+		return fmt.Errorf("OAuth client_credentials mint: HTTP %d: %s", resp.StatusCode, cliutil.SanitizeErrorBody(string(body)))
+	}
+	var tokenResp struct {
+		AccessToken string `json:"access_token"`
+		ExpiresIn   int    `json:"expires_in"`
+	}
+	if err := json.Unmarshal(body, &tokenResp); err != nil {
+		return fmt.Errorf("parsing token response: %w", err)
+	}
+	if tokenResp.AccessToken == "" {
+		return fmt.Errorf("OAuth client_credentials mint: response missing access_token")
+	}
+	expiry := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
+	c.Config.AuthHeaderVal = "" // force AuthHeader() to use the new AccessToken path
+	if err := c.Config.SaveTokens(clientID, clientSecret, tokenResp.AccessToken, "", expiry); err != nil {
+		return fmt.Errorf("saving minted token: %w", err)
+	}
+	return nil
+}
+
+func (c *Client) refreshAccessToken() error {
+	if c.Config == nil {
+		return nil
+	}
+	if c.Config.RefreshToken == "" {
+		return nil
+	}
+
+	tokenURL := "https://api.cc.example/oauth/token"
+	if tokenURL == "" {
+		return nil
+	}
+
+	params := url.Values{
+		"grant_type":    {"refresh_token"},
+		"refresh_token": {c.Config.RefreshToken},
+		"client_id":     {c.Config.ClientID},
+	}
+	if c.Config.ClientSecret != "" {
+		params.Set("client_secret", c.Config.ClientSecret)
+	}
+
+	resp, err := c.HTTPClient.PostForm(tokenURL, params)
+	if err != nil {
+		return fmt.Errorf("refreshing access token: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode >= 400 {
+		body, _ := io.ReadAll(resp.Body)
+		return fmt.Errorf("refreshing access token: HTTP %d: %s", resp.StatusCode, truncateBody(body))
+	}
+
+	var tokenResp struct {
+		AccessToken  string `json:"access_token"`
+		RefreshToken string `json:"refresh_token"`
+		ExpiresIn    int    `json:"expires_in"`
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
+		return fmt.Errorf("parsing refresh response: %w", err)
+	}
+	if tokenResp.AccessToken == "" {
+		return fmt.Errorf("refreshing access token: no access token in response")
+	}
+
+	refreshToken := c.Config.RefreshToken
+	if tokenResp.RefreshToken != "" {
+		refreshToken = tokenResp.RefreshToken
+	}
+
+	expiry := time.Time{}
+	if tokenResp.ExpiresIn > 0 {
+		expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
+	}
+
+	if err := c.Config.SaveTokens(c.Config.ClientID, c.Config.ClientSecret, tokenResp.AccessToken, refreshToken, expiry); err != nil {
+		return fmt.Errorf("saving refreshed token: %w", err)
+	}
+
+	return nil
+}
+
+// sanitizeJSONResponse strips known JSONP/XSSI prefixes and UTF-8 BOM from
+// response bodies so that downstream JSON parsing succeeds. For clean JSON
+// responses these checks are no-ops.
+func sanitizeJSONResponse(body []byte) []byte {
+	// UTF-8 BOM
+	body = bytes.TrimPrefix(body, []byte("\xEF\xBB\xBF"))
+
+	// JSONP/XSSI prefixes, ordered longest-first where prefixes overlap
+	prefixes := [][]byte{
+		[]byte(")]}'\n"),
+		[]byte(")]}'"),
+		[]byte("{}&&"),
+		[]byte("for(;;);"),
+		[]byte("while(1);"),
+	}
+	for _, p := range prefixes {
+		if bytes.HasPrefix(body, p) {
+			body = bytes.TrimPrefix(body, p)
+			body = bytes.TrimLeft(body, " \t\r\n")
+			break
+		}
+	}
+	return body
+}
+
+
+// maskToken redacts all but the last 4 characters of a token for safe display.
+func maskToken(token string) string {
+	if token == "" {
+		return ""
+	}
+	if len(token) <= 4 {
+		return "****"
+	}
+	return "****" + token[len(token)-4:]
+}
+
+func truncateBody(b []byte) string {
+	s := string(b)
+	if len(s) > 200 {
+		return s[:200] + "..."
+	}
+	return s
+}
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go
new file mode 100644
index 00000000..d1bb9e62
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/config/config.go
@@ -0,0 +1,125 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package config
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/pelletier/go-toml/v2"
+)
+
+type Config struct {
+	BaseURL        string `toml:"base_url"`
+	AuthHeaderVal  string `toml:"auth_header"`
+	AuthSource     string `toml:"-"`
+	AccessToken    string `toml:"access_token"`
+	RefreshToken   string `toml:"refresh_token"`
+	TokenExpiry    time.Time `toml:"token_expiry"`
+	ClientID       string `toml:"client_id"`
+	ClientSecret   string `toml:"client_secret"`
+	Path           string `toml:"-"`
+	PrintingPressOauth2Oauth2Cc string `toml:"press_oauth2_oauth2_cc"`
+}
+
+func Load(configPath string) (*Config, error) {
+	cfg := &Config{
+		BaseURL: "https://api.cc.example/v1",
+	}
+
+	// Resolve config path
+	path := configPath
+	if path == "" {
+		path = os.Getenv("PRINTING_PRESS_OAUTH2_CONFIG")
+	}
+	if path == "" {
+		home, _ := os.UserHomeDir()
+		path = filepath.Join(home, ".config", "printing-press-oauth2-pp-cli", "config.toml")
+	}
+	cfg.Path = path
+
+	// Try to load config file
+	data, err := os.ReadFile(path)
+	if err == nil {
+		if err := toml.Unmarshal(data, cfg); err != nil {
+			return nil, fmt.Errorf("parsing config %s: %w", path, err)
+		}
+	}
+
+	// Env var overrides
+	if v := os.Getenv("PRINTING_PRESS_OAUTH2_OAUTH2_CC"); v != "" {
+		cfg.PrintingPressOauth2Oauth2Cc = v
+		cfg.AuthSource = "env:PRINTING_PRESS_OAUTH2_OAUTH2_CC"
+	}
+
+	// Base URL override (used by printing-press verify to point at mock/test servers)
+	if v := os.Getenv("PRINTING_PRESS_OAUTH2_BASE_URL"); v != "" {
+		cfg.BaseURL = v
+	}
+	return cfg, nil
+}
+
+func (c *Config) AuthHeader() string {
+	if c.AuthHeaderVal != "" {
+		return c.AuthHeaderVal
+	}
+	// Under OAuth2 client_credentials the env var is the Client ID, not a
+	// usable bearer; the minted AccessToken must win.
+	if c.AccessToken != "" {
+		c.AuthSource = "oauth2"
+		return "Bearer " + c.AccessToken
+	}
+	if c.PrintingPressOauth2Oauth2Cc != "" {
+		c.AuthSource = "env:PRINTING_PRESS_OAUTH2_OAUTH2_CC"
+		return "Bearer " + c.PrintingPressOauth2Oauth2Cc
+	}
+	return ""
+}
+
+func applyAuthFormat(format string, replacements map[string]string) string {
+	if format == "" {
+		return ""
+	}
+	for key, value := range replacements {
+		format = strings.ReplaceAll(format, "{"+key+"}", value)
+	}
+	if strings.Contains(format, "{") {
+		return ""
+	}
+	return format
+}
+
+func (c *Config) SaveTokens(clientID, clientSecret, accessToken, refreshToken string, expiry time.Time) error {
+	c.ClientID = clientID
+	c.ClientSecret = clientSecret
+	c.AccessToken = accessToken
+	c.RefreshToken = refreshToken
+	c.TokenExpiry = expiry
+	return c.save()
+}
+
+func (c *Config) ClearTokens() error {
+	c.AccessToken = ""
+	c.RefreshToken = ""
+	c.TokenExpiry = time.Time{}
+	return c.save()
+}
+
+func (c *Config) save() error {
+	dir := filepath.Dir(c.Path)
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return fmt.Errorf("creating config dir: %w", err)
+	}
+	data, err := toml.Marshal(c)
+	if err != nil {
+		return fmt.Errorf("marshaling config: %w", err)
+	}
+	return os.WriteFile(c.Path, data, 0o600)
+}
+
+// Ensure strings import is used
+var _ = strings.ReplaceAll
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/stderr.txt b/testdata/golden/expected/generate-golden-api-oauth2-cc/stderr.txt
new file mode 100644
index 00000000..7bcea578
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/stderr.txt
@@ -0,0 +1 @@
+Generated printing-press-oauth2 at <ARTIFACT_DIR>/generate-golden-api-oauth2-cc/printing-press-oauth2-cc
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/stdout.txt b/testdata/golden/expected/generate-golden-api-oauth2-cc/stdout.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/testdata/golden/fixtures/golden-api-oauth2-cc.yaml b/testdata/golden/fixtures/golden-api-oauth2-cc.yaml
new file mode 100644
index 00000000..eadd1e55
--- /dev/null
+++ b/testdata/golden/fixtures/golden-api-oauth2-cc.yaml
@@ -0,0 +1,47 @@
+openapi: "3.0.3"
+info:
+  title: Printing Press OAuth2 CC API
+  version: "1.0.0"
+  description: Purpose-built fixture for the OAuth2 client_credentials auth shape.
+servers:
+  - url: https://api.cc.example/v1
+security:
+  - OAuth2CC: []
+components:
+  securitySchemes:
+    # clientCredentials flow declared. The OpenAPI parser detects this
+    # branch and sets AuthConfig.OAuth2Grant=client_credentials, which
+    # gates: (1) generator template selection (auth_client_credentials
+    # vs auth.go.tmpl), (2) AuthHeader precedence (AccessToken-first vs
+    # env-first), (3) client.go mint helpers + ccMu mutex emission.
+    OAuth2CC:
+      type: oauth2
+      flows:
+        clientCredentials:
+          tokenUrl: https://api.cc.example/oauth/token
+          scopes:
+            read: Read access
+            write: Write access
+  schemas:
+    Item:
+      type: object
+      properties:
+        id:
+          type: string
+        name:
+          type: string
+paths:
+  /items:
+    get:
+      tags: [items]
+      operationId: listItems
+      summary: List items
+      responses:
+        "200":
+          description: OK
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: "#/components/schemas/Item"

← e761d04d fix(cli): FedEx retro batch — 5 small fixes (WU-2, WU-3, WU-  ·  back to Cli Printing Press  ·  chore(main): release 3.8.0 (#527) 7364324a →