[object Object]

← back to Cli Printing Press

fix(cli): honor auth.prefix on bearer_token specs (#1054)

d2770e1610f5ab38dcee000ff7a941732174be39 · 2026-05-11 05:33:06 -0700 · Trevin Chow

* fix(cli): honor auth.prefix on bearer_token specs

The bearer_token branches of `internal/config/config.go` (and the per-tier
bearer branch in `internal/client/client.go`) emitted hardcoded `"Bearer "`
even when the spec declared `auth.prefix: "Token"` (Monarch Money), lowercase
`token` (GitHub legacy PATs), or `PRIVATE-TOKEN` (GitLab PATs). Operators had
to hand-edit generated config and re-edit on every regen.

- Add `Prefix string` to `spec.AuthConfig` plus `HeaderPrefix()` helper that
  returns `Prefix` when set, `"Bearer"` otherwise. `Auth.Format`, when set,
  still wins via the existing `applyAuthFormat` machinery.
- Swap the literal `"Bearer "` for `{{.Auth.HeaderPrefix}}` at five sites in
  `config.go.tmpl` (oauth2/cc, bearer_refresh AccessToken, env-var OR,
  canonical env-var, AccessToken fallback) and one site in `client.go.tmpl`
  (tier-routing bearer).
- Update the AuthProtocol scorer: thread `scheme.Prefix` through
  `openAPISecurityScheme` and `internalSpecToOpenAPISpecInfo`, then accept
  the configured prefix as a valid literal in `scoreAuthScheme`. Without
  this, a non-Bearer prefix would silently drop 3 AuthProtocol points.
- Validate the field at spec-load time: reject characters outside the
  RFC 7235 token charset and cap length at 32. Defense-in-depth against an
  operator-authored prefix accidentally breaking out of the Go string literal
  the generator emits.

Closes #909

* test(cli): pin auth.prefix at oauth2/cc, bearer_refresh, OR-case, and validator wiring

The initial change covered the canonical env-var and AccessToken
fallback branches but left three rendering paths untested. A revert of
any of those template sites back to the literal "Bearer " would have
shipped undetected.

- TestAuthHeader_BearerTokenPrefixMissedSites exercises
  config.go.tmpl's oauth2/client_credentials AccessToken branch,
  BearerRefresh.Enabled AccessToken branch, and the
  $isAuthEnvVarORCase env-var branch with a non-Bearer prefix.
- TestAPISpecValidate_RejectsBadAuthPrefix pins validateAuthPrefix
  into the APISpec.Validate() pipeline so a future reorder cannot
  silently drop the charset gate.
- TestScoreAuthScheme_BearerPrefixOverride now compares scores
  relatively (with-prefix > without-prefix) instead of asserting
  absolute >= 7 / < 7 thresholds that would false-pass under weight
  rebalancing.

Files touched

Diff

commit d2770e1610f5ab38dcee000ff7a941732174be39
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon May 11 05:33:06 2026 -0700

    fix(cli): honor auth.prefix on bearer_token specs (#1054)
    
    * fix(cli): honor auth.prefix on bearer_token specs
    
    The bearer_token branches of `internal/config/config.go` (and the per-tier
    bearer branch in `internal/client/client.go`) emitted hardcoded `"Bearer "`
    even when the spec declared `auth.prefix: "Token"` (Monarch Money), lowercase
    `token` (GitHub legacy PATs), or `PRIVATE-TOKEN` (GitLab PATs). Operators had
    to hand-edit generated config and re-edit on every regen.
    
    - Add `Prefix string` to `spec.AuthConfig` plus `HeaderPrefix()` helper that
      returns `Prefix` when set, `"Bearer"` otherwise. `Auth.Format`, when set,
      still wins via the existing `applyAuthFormat` machinery.
    - Swap the literal `"Bearer "` for `{{.Auth.HeaderPrefix}}` at five sites in
      `config.go.tmpl` (oauth2/cc, bearer_refresh AccessToken, env-var OR,
      canonical env-var, AccessToken fallback) and one site in `client.go.tmpl`
      (tier-routing bearer).
    - Update the AuthProtocol scorer: thread `scheme.Prefix` through
      `openAPISecurityScheme` and `internalSpecToOpenAPISpecInfo`, then accept
      the configured prefix as a valid literal in `scoreAuthScheme`. Without
      this, a non-Bearer prefix would silently drop 3 AuthProtocol points.
    - Validate the field at spec-load time: reject characters outside the
      RFC 7235 token charset and cap length at 32. Defense-in-depth against an
      operator-authored prefix accidentally breaking out of the Go string literal
      the generator emits.
    
    Closes #909
    
    * test(cli): pin auth.prefix at oauth2/cc, bearer_refresh, OR-case, and validator wiring
    
    The initial change covered the canonical env-var and AccessToken
    fallback branches but left three rendering paths untested. A revert of
    any of those template sites back to the literal "Bearer " would have
    shipped undetected.
    
    - TestAuthHeader_BearerTokenPrefixMissedSites exercises
      config.go.tmpl's oauth2/client_credentials AccessToken branch,
      BearerRefresh.Enabled AccessToken branch, and the
      $isAuthEnvVarORCase env-var branch with a non-Bearer prefix.
    - TestAPISpecValidate_RejectsBadAuthPrefix pins validateAuthPrefix
      into the APISpec.Validate() pipeline so a future reorder cannot
      silently drop the charset gate.
    - TestScoreAuthScheme_BearerPrefixOverride now compares scores
      relatively (with-prefix > without-prefix) instead of asserting
      absolute >= 7 / < 7 thresholds that would false-pass under weight
      rebalancing.
---
 internal/generator/auth_env_precedence_test.go | 216 +++++++++++++++++++++++++
 internal/generator/templates/client.go.tmpl    |   2 +-
 internal/generator/templates/config.go.tmpl    |  10 +-
 internal/pipeline/scorecard.go                 |  10 +-
 internal/pipeline/scorecard_tier2_test.go      |  25 +++
 internal/pipeline/spec_detect.go               |   1 +
 internal/spec/spec.go                          |  40 +++++
 internal/spec/spec_test.go                     |  86 ++++++++++
 8 files changed, 383 insertions(+), 7 deletions(-)

diff --git a/internal/generator/auth_env_precedence_test.go b/internal/generator/auth_env_precedence_test.go
index d7f25d6f..5165ec93 100644
--- a/internal/generator/auth_env_precedence_test.go
+++ b/internal/generator/auth_env_precedence_test.go
@@ -186,6 +186,222 @@ func TestAuthHeader_EnvVarWinsOverFileToken(t *testing.T) {
 	}
 }
 
+// TestAuthHeader_BearerTokenPrefixOverride pins that a bearer_token spec
+// declaring auth.prefix changes the rendered Authorization scheme word
+// across both the env-var and AccessToken branches. APIs that require a
+// non-Bearer scheme (e.g., "Token", "PRIVATE-TOKEN", lowercase "token")
+// otherwise force operators to hand-edit generated config. When auth.prefix
+// is unset, "Bearer" remains the default.
+func TestAuthHeader_BearerTokenPrefixOverride(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name     string
+		prefix   string
+		expected string
+	}{
+		{"default", "", "Bearer"},
+		{"token", "Token", "Token"},
+		{"lowercase", "token", "token"},
+		{"private_token", "PRIVATE-TOKEN", "PRIVATE-TOKEN"},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			apiSpec := minimalSpec("prefix-" + tc.name)
+			apiSpec.Auth = spec.AuthConfig{
+				Type:    "bearer_token",
+				Header:  "Authorization",
+				Prefix:  tc.prefix,
+				EnvVars: []string{"PREFIX_TEST_TOKEN"},
+			}
+
+			outputDir := filepath.Join(t.TempDir(), "prefix-"+tc.name+"-pp-cli")
+			require.NoError(t, New(apiSpec, outputDir).Generate())
+
+			cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+			require.NoError(t, err)
+			body := authHeaderBody(t, string(cfgSrc))
+
+			envField := resolveEnvVarField("PREFIX_TEST_TOKEN")
+			require.Contains(t, body, `return "`+tc.expected+` " + c.`+envField,
+				"env-var branch must render configured prefix")
+			require.Contains(t, body, `return "`+tc.expected+` " + c.AccessToken`,
+				"AccessToken branch must render configured prefix")
+
+			if tc.prefix != "" && tc.expected != "Bearer" {
+				assert.NotContains(t, body, `return "Bearer " + c.`+envField,
+					"default Bearer literal must not leak when prefix is overridden")
+				assert.NotContains(t, body, `return "Bearer " + c.AccessToken`,
+					"default Bearer literal must not leak when prefix is overridden")
+			}
+		})
+	}
+}
+
+// TestAuthHeader_BearerTokenPrefixFormatPrecedence pins that Auth.Format
+// wins over Auth.Prefix at the same call sites, so the documented "Ignored
+// when Format is set" contract survives template restructuring.
+func TestAuthHeader_BearerTokenPrefixFormatPrecedence(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("prefix-format")
+	apiSpec.Auth = spec.AuthConfig{
+		Type:    "bearer_token",
+		Header:  "Authorization",
+		Prefix:  "Token",
+		Format:  "Bearer {token}",
+		EnvVars: []string{"PREFIX_FORMAT_TOKEN"},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "prefix-format-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+	require.NoError(t, err)
+	body := authHeaderBody(t, string(cfgSrc))
+
+	envField := resolveEnvVarField("PREFIX_FORMAT_TOKEN")
+	require.Contains(t, body, `applyAuthFormat("Bearer {token}"`,
+		"Format must render via applyAuthFormat, not via the prefix literal")
+	assert.NotContains(t, body, `return "Token " + c.`+envField,
+		"Prefix must not leak into the env-var branch when Format is set")
+	assert.NotContains(t, body, `return "Token " + c.AccessToken`,
+		"Prefix must not leak into the AccessToken branch when Format is set")
+}
+
+// TestAuthHeader_BearerTokenPrefixMissedSites exercises the three
+// non-default code paths in config.go.tmpl that the main override table
+// does not reach: oauth2/client_credentials, BearerRefresh.Enabled, and
+// the $isAuthEnvVarORCase branch. Without these cases a revert of any of
+// those template sites back to a "Bearer " literal would ship undetected.
+func TestAuthHeader_BearerTokenPrefixMissedSites(t *testing.T) {
+	t.Parallel()
+
+	t.Run("oauth2_client_credentials", func(t *testing.T) {
+		t.Parallel()
+		apiSpec := minimalSpec("prefix-oauth2-cc")
+		apiSpec.Auth = spec.AuthConfig{
+			Type:        "bearer_token",
+			Header:      "Authorization",
+			Prefix:      "Token",
+			EnvVarSpecs: []spec.AuthEnvVar{{Name: "CC_PREFIX_CLIENT_ID", Kind: spec.AuthEnvVarKindAuthFlowInput}, {Name: "CC_PREFIX_CLIENT_SECRET", Kind: spec.AuthEnvVarKindAuthFlowInput, Sensitive: true}},
+			OAuth2Grant: spec.OAuth2GrantClientCredentials,
+			TokenURL:    "https://example.com/token",
+		}
+
+		outputDir := filepath.Join(t.TempDir(), "prefix-oauth2-cc-pp-cli")
+		require.NoError(t, New(apiSpec, outputDir).Generate())
+
+		cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+		require.NoError(t, err)
+		body := authHeaderBody(t, string(cfgSrc))
+
+		require.Contains(t, body, `return "Token " + c.AccessToken`,
+			"oauth2/client_credentials AccessToken branch must honor configured prefix")
+		assert.NotContains(t, body, `return "Bearer " + c.AccessToken`,
+			"default Bearer literal must not leak in the oauth2/cc branch when prefix is overridden")
+	})
+
+	t.Run("bearer_refresh_enabled", func(t *testing.T) {
+		t.Parallel()
+		apiSpec := minimalSpec("prefix-bearer-refresh")
+		apiSpec.Auth = spec.AuthConfig{
+			Type:    "bearer_token",
+			Header:  "Authorization",
+			Prefix:  "Token",
+			EnvVars: []string{"REFRESH_PREFIX_TOKEN"},
+		}
+		apiSpec.BearerRefresh = spec.BearerRefreshConfig{
+			BundleURL: "https://cdn.example.com/main.js",
+			Pattern:   `"(AAAAAAAA[^"]+)"`,
+		}
+
+		outputDir := filepath.Join(t.TempDir(), "prefix-bearer-refresh-pp-cli")
+		require.NoError(t, New(apiSpec, outputDir).Generate())
+
+		cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+		require.NoError(t, err)
+		body := authHeaderBody(t, string(cfgSrc))
+
+		require.Contains(t, body, `return "Token " + c.AccessToken`,
+			"BearerRefresh AccessToken branch must honor configured prefix")
+		assert.NotContains(t, body, `return "Bearer " + c.AccessToken`,
+			"default Bearer literal must not leak in the bearer_refresh branch when prefix is overridden")
+	})
+
+	t.Run("env_var_or_case", func(t *testing.T) {
+		t.Parallel()
+		apiSpec := minimalSpec("prefix-or-case")
+		apiSpec.Auth = spec.AuthConfig{
+			Type:   "bearer_token",
+			Header: "Authorization",
+			Prefix: "Token",
+			EnvVarSpecs: []spec.AuthEnvVar{
+				{Name: "OR_PREFIX_A", Kind: spec.AuthEnvVarKindPerCall, Required: false},
+				{Name: "OR_PREFIX_B", Kind: spec.AuthEnvVarKindPerCall, Required: false},
+			},
+		}
+
+		outputDir := filepath.Join(t.TempDir(), "prefix-or-case-pp-cli")
+		require.NoError(t, New(apiSpec, outputDir).Generate())
+
+		cfgSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+		require.NoError(t, err)
+		body := authHeaderBody(t, string(cfgSrc))
+
+		fieldA := resolveEnvVarField("OR_PREFIX_A")
+		fieldB := resolveEnvVarField("OR_PREFIX_B")
+		require.Contains(t, body, `return "Token " + c.`+fieldA,
+			"OR-case env-var branch must honor configured prefix (first env var)")
+		require.Contains(t, body, `return "Token " + c.`+fieldB,
+			"OR-case env-var branch must honor configured prefix (second env var)")
+		assert.NotContains(t, body, `return "Bearer " + c.`+fieldA,
+			"default Bearer literal must not leak in the OR-case branch when prefix is overridden")
+	})
+}
+
+// TestTierRouting_BearerPrefix pins that the per-tier bearer scheme in
+// client.go.tmpl honors auth.prefix on the tier's auth config, matching
+// the default-tier AuthHeader() behavior.
+func TestTierRouting_BearerPrefix(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("tier-prefix")
+	apiSpec.Auth = spec.AuthConfig{
+		Type:    "bearer_token",
+		Header:  "Authorization",
+		EnvVars: []string{"TIER_PREFIX_TOKEN"},
+	}
+	apiSpec.TierRouting = spec.TierRoutingConfig{
+		DefaultTier: "primary",
+		Tiers: map[string]spec.TierConfig{
+			"primary": {
+				Auth: spec.AuthConfig{
+					Type:    "bearer_token",
+					Header:  "Authorization",
+					Prefix:  "Token",
+					EnvVars: []string{"TIER_PRIMARY_TOKEN"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "tier-prefix-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	clientSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+	require.NoError(t, err)
+	content := string(clientSrc)
+
+	require.Contains(t, content, `value := "Token " + tierValue0`,
+		"per-tier bearer auth must honor configured prefix")
+	assert.NotContains(t, content, `value := "Bearer " + tierValue0`,
+		"default Bearer literal must not leak when tier prefix is overridden")
+}
+
 // 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.
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index c7e16826..d134d50f 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -186,7 +186,7 @@ func (c *Client) authForRequest() (requestAuth, error) {
 		{{- end}}
 		})
 	{{- else if eq $tier.Auth.Type "bearer_token"}}
-		value := "Bearer " + tierValue0
+		value := "{{$tier.Auth.HeaderPrefix}} " + tierValue0
 	{{- else}}
 		value := tierValue0
 	{{- end}}
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index 57318cd2..9dd90532 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -303,7 +303,7 @@ func (c *Config) AuthHeader() string {
 		{{- if .Auth.Format}}
 		return applyAuthFormat("{{.Auth.Format}}", map[string]string{"access_token": c.AccessToken, "token": c.AccessToken})
 		{{- else}}
-		return "Bearer " + c.AccessToken
+		return "{{.Auth.HeaderPrefix}} " + c.AccessToken
 		{{- end}}
 	}
 	return ""
@@ -316,7 +316,7 @@ func (c *Config) AuthHeader() string {
 		{{- if .Auth.Format}}
 		return applyAuthFormat("{{.Auth.Format}}", map[string]string{"access_token": c.AccessToken, "token": c.AccessToken})
 		{{- else}}
-		return "Bearer " + c.AccessToken
+		return "{{.Auth.HeaderPrefix}} " + c.AccessToken
 		{{- end}}
 	}
 {{- else}}
@@ -335,7 +335,7 @@ func (c *Config) AuthHeader() string {
 			{{- end}}
 		})
 		{{- else}}
-		return "Bearer " + c.{{resolveEnvVarField .Name}}
+		return "{{$.Auth.HeaderPrefix}} " + c.{{resolveEnvVarField .Name}}
 		{{- end}}
 	}
 {{- end}}
@@ -352,7 +352,7 @@ func (c *Config) AuthHeader() string {
 			{{- end}}
 		})
 		{{- else}}
-		return "Bearer " + c.{{resolveEnvVarField .Name}}
+		return "{{$.Auth.HeaderPrefix}} " + c.{{resolveEnvVarField .Name}}
 		{{- end}}
 	}
 	{{- end}}
@@ -363,7 +363,7 @@ func (c *Config) AuthHeader() string {
 		{{- if .Auth.Format}}
 		return applyAuthFormat("{{.Auth.Format}}", map[string]string{"access_token": c.AccessToken, "token": c.AccessToken})
 		{{- else}}
-		return "Bearer " + c.AccessToken
+		return "{{.Auth.HeaderPrefix}} " + c.AccessToken
 		{{- end}}
 	}
 {{- end}}
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
index 4b42099b..85cddb59 100644
--- a/internal/pipeline/scorecard.go
+++ b/internal/pipeline/scorecard.go
@@ -1499,6 +1499,10 @@ type openAPISecurityScheme struct {
 	Scheme     string
 	In         string
 	HeaderName string
+	// Prefix mirrors apispec.AuthConfig.Prefix for internal-YAML specs that
+	// override the literal "Bearer" scheme word (e.g., "Token", "PRIVATE-TOKEN").
+	// Empty for OpenAPI-derived schemes; bearer-branch scoring falls back to "Bearer".
+	Prefix string
 }
 
 const (
@@ -1913,7 +1917,11 @@ func scoreAuthScheme(clientContent, configContent, authContent string, scheme op
 		}
 	case strings.Contains(nameLower, "bearer") || (scheme.Type == "http" && scheme.Scheme == "bearer"):
 		scoreable = true
-		if authPrefixLiteralPresent("Bearer", clientContent, configContent, authContent) {
+		bearerLiteral := scheme.Prefix
+		if strings.TrimSpace(bearerLiteral) == "" {
+			bearerLiteral = "Bearer"
+		}
+		if authPrefixLiteralPresent(bearerLiteral, clientContent, configContent, authContent) {
 			authHeaderMatched = true
 		}
 	case strings.Contains(nameLower, "basic") || (scheme.Type == "http" && scheme.Scheme == "basic"):
diff --git a/internal/pipeline/scorecard_tier2_test.go b/internal/pipeline/scorecard_tier2_test.go
index 2cdef980..58e5e7ab 100644
--- a/internal/pipeline/scorecard_tier2_test.go
+++ b/internal/pipeline/scorecard_tier2_test.go
@@ -2339,3 +2339,28 @@ func writeScorecardFixture(t *testing.T, root, relPath, content string) {
 		t.Fatalf("write %s: %v", relPath, err)
 	}
 }
+
+// TestScoreAuthScheme_BearerPrefixOverride pins that the AuthProtocol scorer
+// accepts a non-Bearer scheme literal when openAPISecurityScheme.Prefix is
+// populated by an internal-YAML spec. The relative comparison (with-Prefix
+// scores higher than without) survives weight rebalancing where an absolute
+// threshold would not.
+func TestScoreAuthScheme_BearerPrefixOverride(t *testing.T) {
+	configWithToken := `func (c *Config) AuthHeader() string { return "Token " + c.AccessToken }`
+	clientStub := `req.Header.Set("Authorization", c.AuthHeader())`
+
+	withPrefix := openAPISecurityScheme{
+		Key:    "bearer_token",
+		Type:   "http",
+		Scheme: "bearer",
+		Prefix: "Token",
+	}
+	withoutPrefix := withPrefix
+	withoutPrefix.Prefix = ""
+
+	scoreWith, _ := scoreAuthScheme(clientStub, configWithToken, "", withPrefix)
+	scoreWithout, _ := scoreAuthScheme(clientStub, configWithToken, "", withoutPrefix)
+
+	assert.Greater(t, scoreWith, scoreWithout,
+		"AuthProtocol score with configured prefix must exceed the empty-prefix default when generated code uses the prefix literal")
+}
diff --git a/internal/pipeline/spec_detect.go b/internal/pipeline/spec_detect.go
index c8533f83..8ec5549f 100644
--- a/internal/pipeline/spec_detect.go
+++ b/internal/pipeline/spec_detect.go
@@ -134,6 +134,7 @@ func internalSpecToOpenAPISpecInfo(s *apispec.APISpec) *openAPISpecInfo {
 		case "bearer_token":
 			scheme.Type = "http"
 			scheme.Scheme = "bearer"
+			scheme.Prefix = s.Auth.Prefix
 		case "api_key":
 			scheme.Type = "apikey"
 			scheme.In = s.Auth.In
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 218a3412..e48d402c 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -444,6 +444,7 @@ func (c BearerRefreshConfig) Enabled() bool {
 type AuthConfig struct {
 	Type             string       `yaml:"type" json:"type"` // api_key, oauth2, bearer_token, cookie, composed, session_handshake, none
 	Header           string       `yaml:"header" json:"header"`
+	Prefix           string       `yaml:"prefix,omitempty" json:"prefix,omitempty"` // Authorization scheme word (e.g., "Token", "PRIVATE-TOKEN"); empty defaults to "Bearer". Ignored when Format is set.
 	Format           string       `yaml:"format" json:"format"`
 	EnvVars          []string     `yaml:"env_vars" json:"env_vars"`
 	EnvVarSpecs      []AuthEnvVar `yaml:"env_var_specs,omitempty" json:"env_var_specs,omitempty"`
@@ -556,6 +557,16 @@ func (v AuthEnvVar) MarkdownDescription() string {
 	return strings.ReplaceAll(description, "\r", " ")
 }
 
+// HeaderPrefix returns Prefix when set, "Bearer" otherwise. Callers only
+// consult it when Auth.Format is empty; Format's placeholder template
+// already carries its own prefix and takes precedence.
+func (c AuthConfig) HeaderPrefix() string {
+	if p := strings.TrimSpace(c.Prefix); p != "" {
+		return p
+	}
+	return "Bearer"
+}
+
 // CanonicalEnvVar returns the deterministic canonical entry for human-prose surfaces.
 func (c *AuthConfig) CanonicalEnvVar() *AuthEnvVar {
 	if c == nil {
@@ -706,6 +717,32 @@ func (c AuthConfig) EffectiveOAuth2Grant() string {
 
 // validateOAuth2Grant ensures OAuth2Grant is empty or one of the supported
 // values. Empty is accepted (treated as the default). Cross-checking against
+// validateAuthPrefix rejects characters that would break out of the Go
+// double-quoted string literal the generator emits at the prefix interpolation
+// sites (`return "<prefix> " + c.Token`). RFC 7235 only permits token
+// characters in the scheme word anyway, so the cap is both safety and spec
+// adherence. Length is bounded so a typo doesn't balloon every printed CLI's
+// AuthHeader return value.
+func validateAuthPrefix(c AuthConfig) error {
+	prefix := c.Prefix
+	if prefix == "" {
+		return nil
+	}
+	if len(prefix) > 32 {
+		return fmt.Errorf("auth.prefix length %d exceeds 32-character cap", len(prefix))
+	}
+	for i, r := range prefix {
+		if r > 0x7E || r < 0x21 {
+			return fmt.Errorf("auth.prefix contains non-printable or non-ASCII byte at index %d (0x%02x); only RFC 7235 token characters are allowed", i, r)
+		}
+		switch r {
+		case '"', '\\', '(', ')', ',', '/', ':', ';', '<', '=', '>', '?', '@', '[', ']', '{', '}':
+			return fmt.Errorf("auth.prefix contains separator character %q at index %d; only RFC 7235 token characters are allowed", r, i)
+		}
+	}
+	return nil
+}
+
 // 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 {
@@ -1578,6 +1615,9 @@ func (s *APISpec) Validate() error {
 	if err := validateOAuth2Grant(s.Auth); err != nil {
 		return err
 	}
+	if err := validateAuthPrefix(s.Auth); err != nil {
+		return err
+	}
 	if err := validateSessionHandshake(s.Auth); err != nil {
 		return err
 	}
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index f9c081a7..970d0f1f 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -3,6 +3,7 @@ package spec
 import (
 	"bytes"
 	"os"
+	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -855,6 +856,91 @@ func TestThrottlingValidate(t *testing.T) {
 	}
 }
 
+func TestAuthPrefixValidate(t *testing.T) {
+	tests := []struct {
+		name    string
+		prefix  string
+		wantErr string
+	}{
+		{name: "empty is valid (defaults to Bearer)", prefix: ""},
+		{name: "Bearer is valid", prefix: "Bearer"},
+		{name: "Token is valid", prefix: "Token"},
+		{name: "lowercase token is valid", prefix: "token"},
+		{name: "PRIVATE-TOKEN is valid (hyphen is a token char)", prefix: "PRIVATE-TOKEN"},
+		{name: "embedded quote is rejected", prefix: `Token"`, wantErr: "separator character"},
+		{name: "backslash is rejected", prefix: `Token\`, wantErr: "separator character"},
+		{name: "carriage return is rejected", prefix: "Token\r", wantErr: "non-printable"},
+		{name: "newline is rejected", prefix: "Token\n", wantErr: "non-printable"},
+		{name: "space is rejected", prefix: "Token Foo", wantErr: "non-printable"},
+		{name: "non-ASCII is rejected", prefix: "Tøken", wantErr: "non-ASCII"},
+		{name: "over-long prefix is rejected", prefix: strings.Repeat("A", 33), wantErr: "32-character cap"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := validateAuthPrefix(AuthConfig{Prefix: tt.prefix})
+			if tt.wantErr == "" {
+				require.NoError(t, err)
+				return
+			}
+			require.Error(t, err)
+			assert.Contains(t, err.Error(), tt.wantErr)
+		})
+	}
+}
+
+func TestAPISpecValidate_RejectsBadAuthPrefix(t *testing.T) {
+	build := func(prefix string) APISpec {
+		return APISpec{
+			Name:    "prefix-validate",
+			BaseURL: "https://api.example.com",
+			Auth: AuthConfig{
+				Type:    "bearer_token",
+				Header:  "Authorization",
+				Prefix:  prefix,
+				EnvVars: []string{"PREFIX_VALIDATE_TOKEN"},
+			},
+			Resources: map[string]Resource{
+				"items": {
+					Endpoints: map[string]Endpoint{
+						"list": {Method: "GET", Path: "/items"},
+					},
+				},
+			},
+		}
+	}
+
+	t.Run("valid prefix passes Validate()", func(t *testing.T) {
+		s := build("Token")
+		require.NoError(t, s.Validate())
+	})
+
+	t.Run("embedded quote is rejected at the APISpec level", func(t *testing.T) {
+		s := build(`Token"`)
+		err := s.Validate()
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), "auth.prefix")
+	})
+}
+
+func TestAuthConfigHeaderPrefix(t *testing.T) {
+	tests := []struct {
+		name   string
+		prefix string
+		want   string
+	}{
+		{name: "empty defaults to Bearer", prefix: "", want: "Bearer"},
+		{name: "whitespace-only defaults to Bearer", prefix: "   ", want: "Bearer"},
+		{name: "Token is preserved", prefix: "Token", want: "Token"},
+		{name: "surrounding whitespace is trimmed", prefix: "  Token  ", want: "Token"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := AuthConfig{Prefix: tt.prefix}.HeaderPrefix()
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}
+
 func TestOAuth2GrantValidate(t *testing.T) {
 	tests := []struct {
 		name    string

← e2f3a53c fix(cli): honor --resources filter in dependent sync fan-out  ·  back to Cli Printing Press  ·  fix(cli): walk parent dirs for research.json in live-check ( 9a07f287 →