[object Object]

← back to Cli Printing Press

feat(cli): infer API auth from spec description when securitySchemes missing (#126)

75ad9cdbde6f63e245fc95128ec30f6b4b9174ac · 2026-04-04 20:15:55 -0700 · Trevin Chow

* feat(cli): infer auth from spec description when securitySchemes missing

Third-tier fallback in the auth detection pipeline: when both
selectSecurityScheme and inferQueryParamAuth produce nothing, scan
info.description for auth keywords.

Bifurcates by keyword type:
- Bearer keywords (bearer, access token, auth token) → bearer_token
  with _TOKEN env var suffix
- API-key keywords (api key, api_key, sk_live_, cal_live_) → api_key
  with _API_KEY env var suffix

Includes negation guard: skips matches near "not", "no", "without",
"unnecessary", "optional" to avoid false positives on descriptions
like "does not require Bearer authentication".

Adds Auth.Inferred bool field to AuthConfig so downstream templates
can annotate inferred auth.

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

* feat(cli): annotate inferred auth in config and doctor templates

config.go.tmpl: adds a Go comment above the env var block when
Auth.Inferred is true, so developers reading the generated code know
auth was guessed from the spec description.

doctor.go.tmpl: reports inferred auth as WARN with "inferred
(configured — verify this is correct)" or "inferred (not configured)"
with a hint to set the env var. This makes the inference visible at
runtime (doctor output), not just in source code.

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

* fix(cli): scorer recognizes inferred auth when securitySchemes absent

evaluateAuthProtocol returned unscored when SecurityRequirements was
empty. Since inference fires precisely when securitySchemes are absent,
inferred-auth CLIs were always unscored — contradicting R6.

Now checks config.go for os.Getenv patterns as a fallback signal. If
env var support exists without securitySchemes, scores based on what
the CLI actually has: env var support (+4), auth header in client (+3),
inferred annotation (+1).

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

* fix(cli): scorer auth detection, word-boundary negation, doctor hint

Three fixes from Codex review:

1. Scorer: check for auth-specific env var patterns (_API_KEY, _TOKEN)
   instead of any os.Getenv. The BASE_URL env var is always present and
   was causing genuinely unauthenticated CLIs to score auth points.

2. Negation: use word-boundary matching so "Notion" doesn't falsely
   trigger the "no" negation check. Checks char before/after the
   negation word for space, comma, period, or string boundary.

3. Doctor: hints user to verify both header and env var for inferred
   auth, since the header defaults to Authorization which may be wrong
   for APIs expecting X-API-Key or similar.

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

* fix(cli): scorer uses inferred marker not env vars, extract custom header names

Two fixes from Codex adversarial review:

1. Scorer: use "Auth inferred" comment in config.go as the
   discriminator, not env var name patterns. inferQueryParamAuth also
   produces _API_KEY env vars for query-param auth — scoring those as
   inferred header auth penalized correct query-param implementations.
   Now only scores the inferred path when the explicit marker is present.

2. Header detection: scan description for common custom header names
   (X-Api-Key, X-Auth-Token, X-Access-Token) before defaulting to
   Authorization. A spec saying "send your API key in X-Api-Key" now
   produces Header="X-Api-Key" instead of the wrong "Authorization".

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

* feat(skills): pre-generation auth enrichment from research intelligence

When sniff/crowd-sniff produces a spec with no auth but Phase 1
research found auth signals (MCP source, research brief, user
confirmation), the skill now enriches the spec YAML before running
generate — not after.

This ensures all templates (config, client, doctor, auth, README)
produce correct auth from the start, instead of the old approach
that patched only config.go post-generation and missed doctor checks,
client headers, README sections, and auth command setup.

The post-generation auth check remains as a safety net for cases
where enrichment was missed.

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

* fix(cli): scan past negated keyword matches, score custom auth headers

Two fixes from Codex review:

1. Parser: findUnnegated() scans ALL occurrences of a keyword, not just
   the first. "sandbox does not require bearer, but production uses a
   bearer token" now correctly infers auth from the second mention.

2. Scorer: recognize custom auth headers (X-Api-Key, X-Auth-Token,
   X-Access-Token) alongside Authorization when scoring inferred auth.
   Without this, CLIs with correctly inferred custom-header auth were
   under-scored.

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

* test(cli): add scorer and precedence tests for auth inference

Fills test coverage gaps identified during dogfood:

Scorer tests (3):
- Inferred auth with marker → scored (not N/A)
- Query-param auth without marker → stays unscored (not penalized)
- Inferred auth with custom header X-Api-Key → scored

Parser precedence tests (2):
- Explicit securitySchemes wins over description keywords (gmail.yaml)
- Query-param auth (tier 2) wins over description (tier 3) when both
  could match — tier 2 returns api_key/query, not bearer_token/header

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

---------

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

Files touched

Diff

commit 75ad9cdbde6f63e245fc95128ec30f6b4b9174ac
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat Apr 4 20:15:55 2026 -0700

    feat(cli): infer API auth from spec description when securitySchemes missing (#126)
    
    * feat(cli): infer auth from spec description when securitySchemes missing
    
    Third-tier fallback in the auth detection pipeline: when both
    selectSecurityScheme and inferQueryParamAuth produce nothing, scan
    info.description for auth keywords.
    
    Bifurcates by keyword type:
    - Bearer keywords (bearer, access token, auth token) → bearer_token
      with _TOKEN env var suffix
    - API-key keywords (api key, api_key, sk_live_, cal_live_) → api_key
      with _API_KEY env var suffix
    
    Includes negation guard: skips matches near "not", "no", "without",
    "unnecessary", "optional" to avoid false positives on descriptions
    like "does not require Bearer authentication".
    
    Adds Auth.Inferred bool field to AuthConfig so downstream templates
    can annotate inferred auth.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * feat(cli): annotate inferred auth in config and doctor templates
    
    config.go.tmpl: adds a Go comment above the env var block when
    Auth.Inferred is true, so developers reading the generated code know
    auth was guessed from the spec description.
    
    doctor.go.tmpl: reports inferred auth as WARN with "inferred
    (configured — verify this is correct)" or "inferred (not configured)"
    with a hint to set the env var. This makes the inference visible at
    runtime (doctor output), not just in source code.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): scorer recognizes inferred auth when securitySchemes absent
    
    evaluateAuthProtocol returned unscored when SecurityRequirements was
    empty. Since inference fires precisely when securitySchemes are absent,
    inferred-auth CLIs were always unscored — contradicting R6.
    
    Now checks config.go for os.Getenv patterns as a fallback signal. If
    env var support exists without securitySchemes, scores based on what
    the CLI actually has: env var support (+4), auth header in client (+3),
    inferred annotation (+1).
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): scorer auth detection, word-boundary negation, doctor hint
    
    Three fixes from Codex review:
    
    1. Scorer: check for auth-specific env var patterns (_API_KEY, _TOKEN)
       instead of any os.Getenv. The BASE_URL env var is always present and
       was causing genuinely unauthenticated CLIs to score auth points.
    
    2. Negation: use word-boundary matching so "Notion" doesn't falsely
       trigger the "no" negation check. Checks char before/after the
       negation word for space, comma, period, or string boundary.
    
    3. Doctor: hints user to verify both header and env var for inferred
       auth, since the header defaults to Authorization which may be wrong
       for APIs expecting X-API-Key or similar.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): scorer uses inferred marker not env vars, extract custom header names
    
    Two fixes from Codex adversarial review:
    
    1. Scorer: use "Auth inferred" comment in config.go as the
       discriminator, not env var name patterns. inferQueryParamAuth also
       produces _API_KEY env vars for query-param auth — scoring those as
       inferred header auth penalized correct query-param implementations.
       Now only scores the inferred path when the explicit marker is present.
    
    2. Header detection: scan description for common custom header names
       (X-Api-Key, X-Auth-Token, X-Access-Token) before defaulting to
       Authorization. A spec saying "send your API key in X-Api-Key" now
       produces Header="X-Api-Key" instead of the wrong "Authorization".
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * feat(skills): pre-generation auth enrichment from research intelligence
    
    When sniff/crowd-sniff produces a spec with no auth but Phase 1
    research found auth signals (MCP source, research brief, user
    confirmation), the skill now enriches the spec YAML before running
    generate — not after.
    
    This ensures all templates (config, client, doctor, auth, README)
    produce correct auth from the start, instead of the old approach
    that patched only config.go post-generation and missed doctor checks,
    client headers, README sections, and auth command setup.
    
    The post-generation auth check remains as a safety net for cases
    where enrichment was missed.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): scan past negated keyword matches, score custom auth headers
    
    Two fixes from Codex review:
    
    1. Parser: findUnnegated() scans ALL occurrences of a keyword, not just
       the first. "sandbox does not require bearer, but production uses a
       bearer token" now correctly infers auth from the second mention.
    
    2. Scorer: recognize custom auth headers (X-Api-Key, X-Auth-Token,
       X-Access-Token) alongside Authorization when scoring inferred auth.
       Without this, CLIs with correctly inferred custom-header auth were
       under-scored.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * test(cli): add scorer and precedence tests for auth inference
    
    Fills test coverage gaps identified during dogfood:
    
    Scorer tests (3):
    - Inferred auth with marker → scored (not N/A)
    - Query-param auth without marker → stays unscored (not penalized)
    - Inferred auth with custom header X-Api-Key → scored
    
    Parser precedence tests (2):
    - Explicit securitySchemes wins over description keywords (gmail.yaml)
    - Query-param auth (tier 2) wins over description (tier 3) when both
      could match — tier 2 returns api_key/query, not bearer_token/header
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/generator/templates/config.go.tmpl |   3 +
 internal/generator/templates/doctor.go.tmpl |  16 ++-
 internal/openapi/parser.go                  | 143 ++++++++++++++++++++++-
 internal/openapi/parser_test.go             | 170 ++++++++++++++++++++++++++++
 internal/pipeline/scorecard.go              |  24 +++-
 internal/pipeline/scorecard_tier2_test.go   | 104 +++++++++++++++++
 internal/spec/spec.go                       |   1 +
 skills/printing-press/SKILL.md              |  58 ++++++++--
 testdata/openapi/bearer-in-description.yaml |  31 +++++
 9 files changed, 532 insertions(+), 18 deletions(-)

diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index f4c8435e..bab35d7d 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -59,6 +59,9 @@ func Load(configPath string) (*Config, error) {
 	}
 
 	// Env var overrides
+{{- if .Auth.Inferred}}
+	// Auth inferred from API description — verify the env var below is correct
+{{- end}}
 {{- range .Auth.EnvVars}}
 	if v := os.Getenv("{{.}}"); v != "" {
 		cfg.{{envVarField .}} = v
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index 9077c299..29edc738 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -39,6 +39,20 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 			// Check auth
 {{- if or (eq .Auth.Type "") (eq .Auth.Type "none")}}
 			report["auth"] = "not required"
+{{- else if .Auth.Inferred}}
+			// Auth was inferred from spec description — warn the user to verify
+			if cfg != nil {
+				header := cfg.AuthHeader()
+				if header == "" {
+					report["auth"] = "inferred (not configured)"
+					report["auth_hint"] = "export {{index .Auth.EnvVars 0}}=<your-key>"
+				} else {
+					report["auth"] = "inferred (configured — verify header and env var are correct)"
+					report["auth_source"] = cfg.AuthSource
+				}
+			} else {
+				report["auth"] = "inferred (not configured)"
+			}
 {{- else if or (eq .Auth.Type "cookie") (eq .Auth.Type "composed")}}
 			if cfg != nil {
 				header := cfg.AuthHeader()
@@ -197,7 +211,7 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 				indicator := green("OK")
 				if strings.Contains(s, "error") || strings.Contains(s, "not configured") || strings.Contains(s, "unreachable") || strings.Contains(s, "invalid") {
 					indicator = red("FAIL")
-				} else if strings.Contains(s, "not ") || strings.Contains(s, "skipped") {
+				} else if strings.Contains(s, "not ") || strings.Contains(s, "skipped") || strings.Contains(s, "inferred") {
 					indicator = yellow("WARN")
 				}
 				fmt.Fprintf(w, "  %s %s: %s\n", indicator, ck.label, s)
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index bea8cb8d..0fd4a2e7 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -261,7 +261,11 @@ func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
 	auth := spec.AuthConfig{Type: "none"}
 	schemeName, scheme := selectSecurityScheme(doc)
 	if scheme == nil {
-		return inferQueryParamAuth(doc, name, auth)
+		result := inferQueryParamAuth(doc, name, auth)
+		if result.Type == "none" {
+			return inferDescriptionAuth(doc, name, result)
+		}
+		return result
 	}
 
 	auth.Scheme = schemeName
@@ -464,9 +468,6 @@ func detectRequiredHeaders(doc *openapi3.T, auth spec.AuthConfig) []spec.Require
 				continue
 			}
 			totalOps++
-			// Use mergeParameters to respect operation-level overrides.
-			// If an operation redefines a path-level header (e.g., makes it
-			// optional or changes the default), the op-level wins.
 			merged := mergeParameters(pathItem, op)
 			for _, p := range merged {
 				if p == nil {
@@ -479,7 +480,6 @@ func detectRequiredHeaders(doc *openapi3.T, auth spec.AuthConfig) []spec.Require
 				h, ok := headers[lower]
 				if !ok {
 					h = &headerInfo{name: p.Name}
-					// Extract default value from schema
 					if p.Schema != nil && p.Schema.Value != nil {
 						if p.Schema.Value.Default != nil {
 							h.defaultValue = fmt.Sprintf("%v", p.Schema.Value.Default)
@@ -511,6 +511,139 @@ func detectRequiredHeaders(doc *openapi3.T, auth spec.AuthConfig) []spec.Require
 	return result
 }
 
+// bearerKeywords indicate Bearer/token auth when found in info.description.
+// Produces Type="bearer_token" with EnvVars suffix "_TOKEN".
+var bearerKeywords = []string{
+	"bearer",
+	"access token",
+	"auth token",
+}
+
+// apiKeyKeywords indicate API-key auth when found in info.description.
+// Produces Type="api_key" with EnvVars suffix "_API_KEY".
+// Only secret-key vendor prefixes (sk_*, cal_*), not publishable (pk_*).
+var apiKeyKeywords = []string{
+	"api key",
+	"api_key",
+	"authorization header",
+	"sk_live_",
+	"sk_test_",
+	"cal_live_",
+}
+
+// negationWords suppress a keyword match when they appear within 5 words
+// before the keyword, catching "does not require Bearer" patterns.
+var negationWords = []string{"not", "no", "without", "unnecessary", "optional"}
+
+// inferDescriptionAuth scans info.description for auth keywords when both
+// selectSecurityScheme and inferQueryParamAuth produce nothing. This is the
+// third and final tier of the auth detection pipeline.
+func inferDescriptionAuth(doc *openapi3.T, name string, fallback spec.AuthConfig) spec.AuthConfig {
+	if doc == nil || doc.Info == nil {
+		return fallback
+	}
+	desc := strings.ToLower(doc.Info.Description)
+	if desc == "" {
+		return fallback
+	}
+
+	envPrefix := strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
+
+	// Check bearer keywords first (stronger signal for Bearer-prefix auth).
+	// Scan all occurrences — a negated first mention ("does not require bearer")
+	// should not prevent finding a later positive mention ("use a bearer token").
+	for _, kw := range bearerKeywords {
+		if findUnnegated(desc, kw) {
+			return spec.AuthConfig{
+				Type:     "bearer_token",
+				In:       "header",
+				Header:   "Authorization",
+				EnvVars:  []string{envPrefix + "_TOKEN"},
+				Inferred: true,
+			}
+		}
+	}
+
+	// Check API key keywords
+	for _, kw := range apiKeyKeywords {
+		if findUnnegated(desc, kw) {
+			return spec.AuthConfig{
+				Type:     "api_key",
+				In:       "header",
+				Header:   detectHeaderName(desc),
+				EnvVars:  []string{envPrefix + "_API_KEY"},
+				Inferred: true,
+			}
+		}
+	}
+
+	return fallback
+}
+
+// commonCustomHeaders are header names that APIs use instead of Authorization.
+// Checked case-insensitively against the description text.
+var commonCustomHeaders = []string{
+	"X-Api-Key",
+	"X-API-Key",
+	"X-Auth-Token",
+	"X-Access-Token",
+}
+
+// detectHeaderName scans description text for a known custom auth header name.
+// Returns the canonical casing if found, "Authorization" otherwise.
+func detectHeaderName(desc string) string {
+	lower := strings.ToLower(desc)
+	for _, h := range commonCustomHeaders {
+		if strings.Contains(lower, strings.ToLower(h)) {
+			return h
+		}
+	}
+	return "Authorization"
+}
+
+// findUnnegated scans all occurrences of keyword in text and returns true if
+// at least one is not negated. Handles "sandbox does not require bearer, but
+// production uses a bearer token" by scanning past the first negated match.
+func findUnnegated(text, keyword string) bool {
+	offset := 0
+	for {
+		idx := strings.Index(text[offset:], keyword)
+		if idx < 0 {
+			return false
+		}
+		absIdx := offset + idx
+		if !isNegated(text, absIdx) {
+			return true
+		}
+		offset = absIdx + len(keyword)
+	}
+}
+
+// isNegated checks if any negation word appears as a whole word within ~50 chars
+// before the keyword position, catching "does not require Bearer" while avoiding
+// false negation on words like "Notion" that contain "no" as a substring.
+func isNegated(text string, keywordIdx int) bool {
+	start := keywordIdx - 50
+	if start < 0 {
+		start = 0
+	}
+	preceding := text[start:keywordIdx]
+	for _, neg := range negationWords {
+		idx := strings.Index(preceding, neg)
+		if idx < 0 {
+			continue
+		}
+		// Check word boundaries: char before must be space/start, char after must be space/end
+		beforeOk := idx == 0 || preceding[idx-1] == ' ' || preceding[idx-1] == ',' || preceding[idx-1] == '.'
+		afterIdx := idx + len(neg)
+		afterOk := afterIdx >= len(preceding) || preceding[afterIdx] == ' ' || preceding[afterIdx] == ',' || preceding[afterIdx] == '.'
+		if beforeOk && afterOk {
+			return true
+		}
+	}
+	return false
+}
+
 func selectSecurityScheme(doc *openapi3.T) (string, *openapi3.SecurityScheme) {
 	if doc == nil || doc.Components == nil || len(doc.Components.SecuritySchemes) == 0 {
 		return "", nil
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 747448d2..3a5cd159 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -6,6 +6,7 @@ import (
 	"path/filepath"
 	"testing"
 
+	"github.com/getkin/kin-openapi/openapi3"
 	"github.com/mvanhorn/cli-printing-press/internal/generator"
 	"github.com/mvanhorn/cli-printing-press/internal/naming"
 	"github.com/mvanhorn/cli-printing-press/internal/spec"
@@ -478,3 +479,172 @@ func TestDetectRequiredHeaders(t *testing.T) {
 		assert.Empty(t, headers)
 	})
 }
+
+func TestInferDescriptionAuth(t *testing.T) {
+	t.Parallel()
+
+	t.Run("bearer in description, no securitySchemes", func(t *testing.T) {
+		data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "bearer-in-description.yaml"))
+		require.NoError(t, err)
+
+		parsed, err := Parse(data)
+		require.NoError(t, err)
+
+		assert.Equal(t, "bearer_token", parsed.Auth.Type)
+		assert.Equal(t, "Authorization", parsed.Auth.Header)
+		assert.Equal(t, "header", parsed.Auth.In)
+		assert.True(t, parsed.Auth.Inferred)
+		assert.NotEmpty(t, parsed.Auth.EnvVars)
+		assert.Contains(t, parsed.Auth.EnvVars[0], "_TOKEN")
+	})
+
+	t.Run("petstore has explicit auth, not inferred", func(t *testing.T) {
+		data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "petstore.yaml"))
+		require.NoError(t, err)
+
+		parsed, err := Parse(data)
+		require.NoError(t, err)
+
+		assert.False(t, parsed.Auth.Inferred)
+		assert.NotEqual(t, "none", parsed.Auth.Type)
+	})
+
+	t.Run("stytch has explicit auth, not inferred", func(t *testing.T) {
+		data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "stytch.yaml"))
+		require.NoError(t, err)
+
+		parsed, err := Parse(data)
+		require.NoError(t, err)
+
+		assert.False(t, parsed.Auth.Inferred)
+	})
+
+	t.Run("no auth keywords in description stays none", func(t *testing.T) {
+		doc := &openapi3.T{
+			Info: &openapi3.Info{
+				Description: "A simple API for managing widgets and gadgets.",
+			},
+		}
+		result := inferDescriptionAuth(doc, "widgets", spec.AuthConfig{Type: "none"})
+		assert.Equal(t, "none", result.Type)
+		assert.False(t, result.Inferred)
+	})
+
+	t.Run("negation suppresses inference", func(t *testing.T) {
+		result := inferDescriptionAuth(nil, "test", spec.AuthConfig{Type: "none"})
+		assert.Equal(t, "none", result.Type)
+
+		doc := &openapi3.T{
+			Info: &openapi3.Info{
+				Description: "This API does not require Bearer authentication",
+			},
+		}
+		result = inferDescriptionAuth(doc, "test", spec.AuthConfig{Type: "none"})
+		assert.Equal(t, "none", result.Type, "negated 'Bearer' should not trigger inference")
+		assert.False(t, result.Inferred)
+	})
+
+	t.Run("api_key keyword produces api_key type", func(t *testing.T) {
+		doc := &openapi3.T{
+			Info: &openapi3.Info{
+				Description: "Authenticate with your API key in the Authorization header",
+			},
+		}
+		result := inferDescriptionAuth(doc, "example", spec.AuthConfig{Type: "none"})
+		assert.Equal(t, "api_key", result.Type)
+		assert.Equal(t, "EXAMPLE_API_KEY", result.EnvVars[0])
+		assert.True(t, result.Inferred)
+	})
+
+	t.Run("scans past negated match to find positive mention", func(t *testing.T) {
+		doc := &openapi3.T{
+			Info: &openapi3.Info{
+				Description: "Sandbox requests do not require a bearer token, but production requests use a bearer token for authentication.",
+			},
+		}
+		result := inferDescriptionAuth(doc, "example", spec.AuthConfig{Type: "none"})
+		assert.Equal(t, "bearer_token", result.Type, "should find the second non-negated 'bearer' mention")
+		assert.True(t, result.Inferred)
+	})
+
+	t.Run("Notion bearer token not falsely negated", func(t *testing.T) {
+		doc := &openapi3.T{
+			Info: &openapi3.Info{
+				Description: "Use your Notion bearer token to authenticate",
+			},
+		}
+		result := inferDescriptionAuth(doc, "notion", spec.AuthConfig{Type: "none"})
+		assert.Equal(t, "bearer_token", result.Type, "'Notion' contains 'no' but should not trigger negation")
+		assert.True(t, result.Inferred)
+	})
+
+	t.Run("custom header X-Api-Key extracted from description", func(t *testing.T) {
+		doc := &openapi3.T{
+			Info: &openapi3.Info{
+				Description: "Send your API key in the X-Api-Key header",
+			},
+		}
+		result := inferDescriptionAuth(doc, "example", spec.AuthConfig{Type: "none"})
+		assert.Equal(t, "api_key", result.Type)
+		assert.Equal(t, "X-Api-Key", result.Header, "should extract X-Api-Key, not default to Authorization")
+		assert.True(t, result.Inferred)
+	})
+
+	t.Run("nil doc returns fallback", func(t *testing.T) {
+		fb := spec.AuthConfig{Type: "none"}
+		assert.Equal(t, fb, inferDescriptionAuth(nil, "test", fb))
+	})
+}
+
+func TestAuthTierPrecedence(t *testing.T) {
+	t.Parallel()
+
+	t.Run("explicit securitySchemes wins over description keywords", func(t *testing.T) {
+		// Gmail has both securitySchemes AND description that could mention auth
+		data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "gmail.yaml"))
+		require.NoError(t, err)
+
+		parsed, err := Parse(data)
+		require.NoError(t, err)
+
+		assert.Equal(t, "bearer_token", parsed.Auth.Type)
+		assert.False(t, parsed.Auth.Inferred, "explicit auth from securitySchemes should not be marked as inferred")
+	})
+
+	t.Run("query-param auth (tier 2) wins over description (tier 3)", func(t *testing.T) {
+		// Build a minimal spec with auth-like query params on >30% of ops
+		// AND bearer keyword in description. Tier 2 should win.
+		doc := &openapi3.T{
+			Info: &openapi3.Info{
+				Description: "This API uses Bearer token authentication.",
+			},
+			Paths: &openapi3.Paths{},
+		}
+		// Add 5 operations, 3 with api_key query param (60% > 30% threshold)
+		for i, path := range []string{"/a", "/b", "/c", "/d", "/e"} {
+			pathItem := &openapi3.PathItem{
+				Get: &openapi3.Operation{
+					Responses: openapi3.NewResponses(),
+				},
+			}
+			if i < 3 { // first 3 have api_key param
+				pathItem.Get.Parameters = openapi3.Parameters{
+					&openapi3.ParameterRef{
+						Value: &openapi3.Parameter{
+							Name:     "api_key",
+							In:       "query",
+							Required: false,
+						},
+					},
+				}
+			}
+			doc.Paths.Set(path, pathItem)
+		}
+
+		// Run mapAuth directly — it should pick up query-param auth (tier 2)
+		result := mapAuth(doc, "test-api")
+		assert.Equal(t, "api_key", result.Type)
+		assert.Equal(t, "query", result.In, "tier 2 query-param auth should win over tier 3 description")
+		assert.False(t, result.Inferred, "query-param auth is not 'inferred from description'")
+	})
+}
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
index 82181b15..97efe60e 100644
--- a/internal/pipeline/scorecard.go
+++ b/internal/pipeline/scorecard.go
@@ -1050,12 +1050,28 @@ func evaluateAuthProtocol(dir string, spec *openAPISpecInfo) dimensionScore {
 	if spec == nil {
 		return dimensionScore{}
 	}
-	if len(spec.SecurityRequirements) == 0 {
-		return dimensionScore{}
-	}
-
 	clientContent := readFileContent(filepath.Join(dir, "internal", "client", "client.go"))
 	configContent := readFileContent(filepath.Join(dir, "internal", "config", "config.go"))
+
+	if len(spec.SecurityRequirements) == 0 {
+		// No securitySchemes in spec. Check if auth was inferred from description
+		// text (marked with "Auth inferred" comment in generated config.go).
+		// Do NOT match on env var names alone — inferQueryParamAuth also produces
+		// _API_KEY env vars for query-param auth, and scoring those as inferred
+		// header auth would penalize correct query-param implementations.
+		if !strings.Contains(configContent, "Auth inferred") {
+			return dimensionScore{} // no inferred auth marker → skip scoring
+		}
+		// Inferred auth — score based on what the CLI actually has
+		score := 1 // annotated as inferred (user knows to verify)
+		if strings.Contains(configContent, "os.Getenv(") {
+			score += 4 // env var support present
+		}
+		if strings.Contains(clientContent, "Authorization") || strings.Contains(clientContent, "X-Api-Key") || strings.Contains(clientContent, "X-Auth-Token") || strings.Contains(clientContent, "X-Access-Token") {
+			score += 3 // client sends auth header (standard or custom)
+		}
+		return dimensionScore{scored: true, score: score}
+	}
 	authContent := readFileContent(filepath.Join(dir, "internal", "cli", "auth.go"))
 	if clientContent == "" {
 		return dimensionScore{scored: true}
diff --git a/internal/pipeline/scorecard_tier2_test.go b/internal/pipeline/scorecard_tier2_test.go
index 2a388a56..cb6fff48 100644
--- a/internal/pipeline/scorecard_tier2_test.go
+++ b/internal/pipeline/scorecard_tier2_test.go
@@ -967,6 +967,110 @@ func runLookup(db *store.DB) {
 	})
 }
 
+func TestEvaluateAuthProtocol_InferredAuth(t *testing.T) {
+	t.Run("inferred auth is scored when Auth inferred marker present", func(t *testing.T) {
+		dir := t.TempDir()
+
+		// Config with inferred auth marker and env var
+		writeScorecardFixture(t, dir, "internal/config/config.go", `package config
+// Auth inferred from API description — verify the env var below is correct
+func Load() {
+	if v := os.Getenv("EXAMPLE_TOKEN"); v != "" {
+		cfg.Token = v
+	}
+}
+func (c *Config) AuthHeader() string {
+	return "Bearer " + c.Token
+}
+`)
+		// Client sends Authorization header
+		writeScorecardFixture(t, dir, "internal/client/client.go", `package client
+func (c *Client) do() {
+	req.Header.Set("Authorization", authHeader)
+}
+`)
+
+		// Spec with NO securitySchemes
+		specPath := filepath.Join(dir, "spec.json")
+		writeScorecardFixture(t, dir, "spec.json", `{
+  "paths": { "/users": { "get": { "responses": { "200": { "description": "ok" } } } } },
+  "components": { "securitySchemes": {} }
+}`)
+
+		pipelineDir := t.TempDir()
+		sc, err := RunScorecard(dir, pipelineDir, specPath, nil)
+		assert.NoError(t, err)
+		// auth_protocol should be SCORED (not in UnscoredDimensions)
+		assert.NotContains(t, sc.UnscoredDimensions, "auth_protocol",
+			"inferred auth with marker should be scored, not unscored")
+		assert.Greater(t, sc.Steinberger.AuthProtocol, 0,
+			"inferred auth should score > 0")
+	})
+
+	t.Run("query-param auth without inferred marker stays unscored", func(t *testing.T) {
+		dir := t.TempDir()
+
+		// Config with env var but NO "Auth inferred" marker (query-param auth)
+		writeScorecardFixture(t, dir, "internal/config/config.go", `package config
+func Load() {
+	if v := os.Getenv("STEAM_API_KEY"); v != "" {
+		cfg.APIKey = v
+	}
+}
+`)
+		writeScorecardFixture(t, dir, "internal/client/client.go", `package client
+func (c *Client) do() {
+	q.Set("key", apiKey)
+}
+`)
+
+		// Spec with NO securitySchemes (query-param auth was inferred by inferQueryParamAuth)
+		specPath := filepath.Join(dir, "spec.json")
+		writeScorecardFixture(t, dir, "spec.json", `{
+  "paths": { "/users": { "get": { "responses": { "200": { "description": "ok" } } } } },
+  "components": { "securitySchemes": {} }
+}`)
+
+		pipelineDir := t.TempDir()
+		sc, err := RunScorecard(dir, pipelineDir, specPath, nil)
+		assert.NoError(t, err)
+		// auth_protocol should be UNSCORED — no marker, no securitySchemes
+		assert.Contains(t, sc.UnscoredDimensions, "auth_protocol",
+			"query-param auth without inferred marker should stay unscored (not penalized)")
+	})
+
+	t.Run("inferred auth with custom header X-Api-Key is scored", func(t *testing.T) {
+		dir := t.TempDir()
+
+		writeScorecardFixture(t, dir, "internal/config/config.go", `package config
+// Auth inferred from API description — verify the env var below is correct
+func Load() {
+	if v := os.Getenv("EXAMPLE_API_KEY"); v != "" {
+		cfg.APIKey = v
+	}
+}
+`)
+		writeScorecardFixture(t, dir, "internal/client/client.go", `package client
+func (c *Client) do() {
+	req.Header.Set("X-Api-Key", apiKey)
+}
+`)
+
+		specPath := filepath.Join(dir, "spec.json")
+		writeScorecardFixture(t, dir, "spec.json", `{
+  "paths": { "/users": { "get": { "responses": { "200": { "description": "ok" } } } } },
+  "components": { "securitySchemes": {} }
+}`)
+
+		pipelineDir := t.TempDir()
+		sc, err := RunScorecard(dir, pipelineDir, specPath, nil)
+		assert.NoError(t, err)
+		assert.NotContains(t, sc.UnscoredDimensions, "auth_protocol",
+			"inferred auth with custom header should be scored")
+		assert.Greater(t, sc.Steinberger.AuthProtocol, 0)
+	})
+}
+
 func writeScorecardFixture(t *testing.T, root, relPath, content string) {
 	t.Helper()
 
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 2fb754af..5f5390e8 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -48,6 +48,7 @@ type AuthConfig struct {
 	Scopes           []string `yaml:"scopes,omitempty" json:"scopes,omitempty"`
 	CookieDomain     string   `yaml:"cookie_domain,omitempty" json:"cookie_domain,omitempty"` // domain to read browser cookies from (e.g. ".notion.so")
 	Cookies          []string `yaml:"cookies,omitempty" json:"cookies,omitempty"`             // named cookies to extract for composed auth (e.g. ["customerId", "authToken"])
+	Inferred         bool     `yaml:"inferred,omitempty" json:"inferred,omitempty"`           // true when auth was inferred from spec description, not declared in securitySchemes
 }
 
 type ConfigSpec struct {
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index 26c9e44e..86d89e5b 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -946,7 +946,49 @@ Proceed silently to Phase 2.
 
 ## Phase 2: Generate
 
-Use the resolved spec source and generate immediately.
+### Pre-Generation Auth Enrichment
+
+Before generating, check whether the resolved spec has auth. This matters most for
+sniffed and crowd-sniffed specs where the mechanical auth detection may have failed
+(e.g., session expired during sniff, SDK didn't expose auth patterns).
+
+**Check the spec:**
+- For internal YAML specs: look for `auth:` section with `type:` not equal to `"none"`
+- For OpenAPI specs: look for `components.securitySchemes` or `security` sections
+
+**If auth is missing** (`type: none` or no auth section) AND Phase 1 research found
+auth signals, enrich the spec before generation:
+
+1. Check the research brief for auth mentions (Bearer, API key, token, cookie, OAuth)
+2. Check Phase 1.5a MCP source code analysis for auth patterns (header names, token formats)
+3. Check Phase 1.6 Pre-Sniff Auth Intelligence results (if the user confirmed auth)
+
+If any source identified auth, **edit the spec YAML** to add the auth section before
+running generate. For internal YAML specs:
+
+```yaml
+auth:
+  type: bearer_token    # or api_key, depending on what research found
+  header: Authorization # or the specific header from MCP source
+  in: header
+  env_vars:
+    - <API_NAME>_TOKEN  # bearer_token → _TOKEN, api_key → _API_KEY
+```
+
+For OpenAPI specs, add an `info.description` mention if one doesn't exist — the
+parser's `inferDescriptionAuth` will detect it automatically.
+
+**Why enrich before generation, not after:** The generator's templates (config, client,
+doctor, auth, README) all read `Auth.*` fields from the spec. Patching config.go after
+generation only fixes env var support — it misses the doctor auth check, client auth
+header, README auth section, and auth command setup. Enriching the spec means every
+template produces correct auth from the start.
+
+**When to skip:** If the API genuinely doesn't need auth (ESPN public endpoints, weather
+APIs, public data feeds), don't invent auth. The signal must come from research — not
+from guessing. No research mention of auth = no enrichment.
+
+### Lock and Generate
 
 Before running any generate command, acquire the build lock:
 
@@ -1059,13 +1101,13 @@ Cookbook. When rewriting the README for this API during Phase 3, **preserve all
 You may add additional sections that help users of this specific API (e.g., "Rate Limits",
 "Pagination", "Authentication Setup"), but never remove the standard ones.
 
-**REQUIRED: Compensate for missing auth.** Check if the generated `config.go` has auth
-env var support (look for `os.Getenv` calls for API key variables). If not, check the
-Phase 1 research brief for auth requirements. If the brief identifies an API key, token,
-or auth method that the spec didn't declare, add the appropriate env var support to
-`config.go`. Use the pattern: add `APIKey`/`APIKeySource` fields to the Config struct,
-and `os.Getenv("<API>_API_KEY")` in the Load function. The research brief is the
-authoritative source when the spec is silent on auth.
+**REQUIRED: Verify auth was generated.** Check if the generated `config.go` has auth
+env var support (look for `os.Getenv` calls for API key variables). If the
+pre-generation auth enrichment ran correctly, this should already be present. If not
+(enrichment was missed or the spec was ambiguous), this is the safety net: check the
+Phase 1 research brief for auth requirements and manually add env var support to
+`config.go` using the pattern: add `APIKey`/`APIKeySource` fields to the Config struct,
+and `os.Getenv("<API>_API_KEY")` in the Load function.
 
 After the description rewrite, update the lock heartbeat:
 
diff --git a/testdata/openapi/bearer-in-description.yaml b/testdata/openapi/bearer-in-description.yaml
new file mode 100644
index 00000000..b2df53b9
--- /dev/null
+++ b/testdata/openapi/bearer-in-description.yaml
@@ -0,0 +1,31 @@
+openapi: "3.0.0"
+info:
+  title: Bearer Auth API
+  version: "1.0.0"
+  description: >
+    This API uses Bearer token authentication. Include your API key in
+    the Authorization header as a Bearer token. Get your key from the
+    dashboard at https://example.com/settings/api-keys.
+servers:
+  - url: https://api.example.com/v1
+paths:
+  /v1/users:
+    get:
+      operationId: getUsers
+      summary: List users
+      responses:
+        "200":
+          description: OK
+  /v1/users/{id}:
+    get:
+      operationId: getUser
+      summary: Get user by ID
+      parameters:
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: OK

← 79a94585 feat(cli): detect and emit required API headers from OpenAPI  ·  back to Cli Printing Press  ·  fix(skills): inline dogfood protocol steps to prevent skippi a1096f41 →