[object Object]

← back to Cli Printing Press

fix(cli): actionable auth errors with env var names, key URLs, and 400 handling (#92)

151ec979dbcf9529a5e040e09012a30f23862f65 · 2026-03-31 00:08:49 -0700 · Trevin Chow

* feat(cli): add KeyURL field to spec.AuthConfig and DiscoveredAuth

Adds key_url to AuthConfig for the URL where users register for an
API key. Wired through crowd-sniff specgen's buildAuthConfig. Backward
compatible (omitempty). Round-trip tested through YAML parse.

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

* feat(cli): extract key registration URLs from npm SDK READMEs

Crowd-sniff now reads README.md from npm tarballs (separate from the
JS/TS walker) and extracts "Get your API key at [url]" patterns.
URLs must be HTTPS and either contain the API name or be on a known
developer platform domain. Badge URLs and plain GitHub repo links
are filtered out. 100KB README size cap for ReDoS protection.

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

* fix(cli): actionable auth errors with env var names, key URLs, and 400 handling

classifyAPIError now catches HTTP 400 responses with auth keywords
(template-gated: only for specs declaring auth). All auth errors
(400/401/403) show the specific env var, export command, key URL
when known, and doctor command hint. Response bodies truncated to
200 chars with credential-shaped strings stripped.

Doctor output shows "not required" for no-auth APIs and specific
env var + key URL hints when auth is not configured.

MCP error handler updated to match CLI error handler for consistency.

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

* docs(cli): add auth error handling brainstorm and plan

Requirements doc covers 14 requirements across error classification,
message content, doctor output, auth behavior, and key URL discovery.
Plan covers 4 implementation units with document-review findings
integrated (400 condition, walker fix, threat model, MCP parity).

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

* docs(cli): mark auth error handling plan as completed

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

* fix(cli): security fixes from code review — sanitize 401/403, tighten URL filters

- MCP 401/403 paths now use sanitizeErrorBody (gated behind auth type
  to avoid undefined function when auth is none)
- Markdown link regex excludes " and whitespace in URL captures
- Known dev platform URLs now also require API name match to prevent
  developer.evil.com from being accepted as a key URL

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 151ec979dbcf9529a5e040e09012a30f23862f65
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Tue Mar 31 00:08:49 2026 -0700

    fix(cli): actionable auth errors with env var names, key URLs, and 400 handling (#92)
    
    * feat(cli): add KeyURL field to spec.AuthConfig and DiscoveredAuth
    
    Adds key_url to AuthConfig for the URL where users register for an
    API key. Wired through crowd-sniff specgen's buildAuthConfig. Backward
    compatible (omitempty). Round-trip tested through YAML parse.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * feat(cli): extract key registration URLs from npm SDK READMEs
    
    Crowd-sniff now reads README.md from npm tarballs (separate from the
    JS/TS walker) and extracts "Get your API key at [url]" patterns.
    URLs must be HTTPS and either contain the API name or be on a known
    developer platform domain. Badge URLs and plain GitHub repo links
    are filtered out. 100KB README size cap for ReDoS protection.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): actionable auth errors with env var names, key URLs, and 400 handling
    
    classifyAPIError now catches HTTP 400 responses with auth keywords
    (template-gated: only for specs declaring auth). All auth errors
    (400/401/403) show the specific env var, export command, key URL
    when known, and doctor command hint. Response bodies truncated to
    200 chars with credential-shaped strings stripped.
    
    Doctor output shows "not required" for no-auth APIs and specific
    env var + key URL hints when auth is not configured.
    
    MCP error handler updated to match CLI error handler for consistency.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * docs(cli): add auth error handling brainstorm and plan
    
    Requirements doc covers 14 requirements across error classification,
    message content, doctor output, auth behavior, and key URL discovery.
    Plan covers 4 implementation units with document-review findings
    integrated (400 condition, walker fix, threat model, MCP parity).
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * docs(cli): mark auth error handling plan as completed
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): security fixes from code review — sanitize 401/403, tighten URL filters
    
    - MCP 401/403 paths now use sanitizeErrorBody (gated behind auth type
      to avoid undefined function when auth is none)
    - Markdown link regex excludes " and whitespace in URL captures
    - Known dev platform URLs now also require API name match to prevent
      developer.evil.com from being accepted as a key URL
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 .../2026-03-31-auth-error-handling-requirements.md |  83 +++++
 .../2026-03-31-004-fix-auth-error-handling-plan.md | 248 +++++++++++++++
 internal/crowdsniff/npm.go                         | 169 ++++++++++
 internal/crowdsniff/npm_test.go                    | 267 ++++++++++++++++
 internal/crowdsniff/specgen.go                     |   4 +
 internal/crowdsniff/specgen_test.go                | 103 +++++++
 internal/crowdsniff/types.go                       |   1 +
 internal/generator/generator_test.go               | 339 +++++++++++++++++++++
 internal/generator/templates/doctor.go.tmpl        |  21 ++
 internal/generator/templates/helpers.go.tmpl       | 100 +++++-
 internal/generator/templates/mcp_tools.go.tmpl     |  83 ++++-
 internal/spec/spec.go                              |   5 +-
 12 files changed, 1411 insertions(+), 12 deletions(-)

diff --git a/docs/brainstorms/2026-03-31-auth-error-handling-requirements.md b/docs/brainstorms/2026-03-31-auth-error-handling-requirements.md
new file mode 100644
index 00000000..8e20c407
--- /dev/null
+++ b/docs/brainstorms/2026-03-31-auth-error-handling-requirements.md
@@ -0,0 +1,83 @@
+---
+date: 2026-03-31
+topic: auth-error-handling
+---
+
+# Better Auth Error Handling in Generated CLIs
+
+## Problem Frame
+
+Generated CLIs give poor feedback when auth is misconfigured. Steam returns HTTP 400 ("Required parameter 'key' is missing") which the error handler doesn't recognize as auth-related. Error messages are generic ("check your credentials") and don't name the specific env var. The `doctor` command shows "Auth: not configured" without telling the user how to fix it. Users have to figure out the env var name, format, and source on their own.
+
+This is a pipeline problem, not a single-fix problem. The auth metadata (env var name, auth type, where to get a key) flows from crowd-sniff → spec → generator → templates, but the last mile — the generated error messages and doctor output — doesn't use most of it.
+
+## Requirements
+
+**Error Message Quality**
+
+- R1. `classifyAPIError` pattern-matches HTTP 400 response bodies for auth keywords (`key`, `auth`, `token`, `api_key`, `unauthorized`, `missing`) and treats matching 400s as auth errors with actionable hints
+- R2. Auth error messages (400/401/403) include the specific env var name from the spec (e.g., "Set STEAM_API_KEY=..." not "check your credentials")
+- R3. Auth error messages include a one-line setup command: `export STEAM_API_KEY=<your-key>`
+- R4. Non-auth 400 errors (bad input, missing required field, validation) are NOT incorrectly classified as auth errors
+
+**Doctor Command**
+
+- R5. `doctor` output names the specific env var when auth is not configured: `Auth: not configured. Set STEAM_API_KEY in your environment`
+- R6. `doctor` output confirms auth is configured with the source: `Auth: configured (env: STEAM_API_KEY)`
+
+**Mixed-Auth APIs**
+
+- R7. Auth checking is per-call, not per-startup — CLIs with endpoints that don't need auth still work without a key for those endpoints
+- R8. No fail-fast startup gate — the auth error only surfaces when an endpoint that needs auth is called without credentials
+
+**Pipeline: Auth Metadata Propagation**
+
+- R9. The helpers.go.tmpl template has access to `{{.Auth.EnvVars}}` so error messages can reference the correct env var name at generation time
+- R10. The doctor.go.tmpl template has access to `{{.Auth.EnvVars}}` to show specific setup instructions
+
+**Key Registration URL Discovery**
+
+- R11. Add `key_url` field to `spec.AuthConfig` for the URL where users can register/obtain an API key
+- R12. Crowd-sniff extracts key registration URLs from npm SDK READMEs when present (patterns like "Get your API key at [url]", "Register at [url]", "Sign up for an API key: [url]")
+- R13. When `key_url` is populated, error messages and doctor output include it: `Get a key at: https://steamcommunity.com/dev/apikey`
+- R14. When `key_url` is NOT populated, error messages and doctor output omit the URL entirely — no placeholder, no "unknown", just skip that line. A missing URL is better than a wrong one.
+
+## Success Criteria
+
+- Running `steam-pp-cli player GabeLoganNewell` without STEAM_API_KEY shows: `error: API key required. Set STEAM_API_KEY in your environment: export STEAM_API_KEY=<your-key>` followed by `Get a key at: https://steamcommunity.com/dev/apikey` (when key_url is known)
+- Running `steam-pp-cli players 440` without STEAM_API_KEY still works (endpoint doesn't need auth)
+- Running `steam-pp-cli doctor` without STEAM_API_KEY shows: `Auth: not configured. Set STEAM_API_KEY in your environment` + key URL when known
+- Running `steam-pp-cli doctor` with STEAM_API_KEY shows: `Auth: configured (env: STEAM_API_KEY)`
+- For an API where key_url is NOT discovered: error messages show env var name but no URL line
+
+## Scope Boundaries
+
+- No OAuth flow improvements — only API key and bearer token auth
+- No per-endpoint auth metadata in the spec — the spec declares API-wide auth, not per-endpoint
+- No interactive auth setup wizard — just better error messages and doctor output
+- No changes to the crowd-sniff auth detection logic (already improved separately)
+- Key URL discovery is best-effort from SDK READMEs — when not found, omit it cleanly
+
+## Key Decisions
+
+- **Per-call, not per-startup**: Mixed-auth APIs (like Steam) have endpoints that work without auth. A startup gate would block these unnecessarily. The error handler catches failures per-call and shows auth guidance only when relevant.
+- **Pattern-match 400 bodies for auth keywords**: Some APIs return 400 instead of 401 for missing auth. Body pattern matching (`key`, `token`, `auth`, `unauthorized`) is more reliable than just status codes. Combined with "auth not configured" state for high confidence.
+- **Template-time env var embedding**: The env var name is known at generation time from the spec. Embed it directly in the error message template, not at runtime. This is zero-cost at runtime and always correct.
+- **Best-effort key URL, omit when unknown**: Crowd-sniff can extract key registration URLs from ~60% of npm SDKs with popular APIs. When found, it's included in error messages and doctor output. When not found, those lines are simply absent — no "URL unknown" placeholder. The skill's Phase 1 research can also find and manually add the URL to the spec, covering the remaining cases.
+
+## Dependencies / Assumptions
+
+- The spec's `auth.env_vars` field is populated (either from crowd-sniff detection or manual spec authoring)
+- The helpers.go.tmpl and doctor.go.tmpl templates receive the full `*spec.APISpec` (or a struct embedding it) — need to verify helpers currently only gets `*spec.APISpec` vs the enriched `helpersTemplateData`
+
+## Outstanding Questions
+
+### Deferred to Planning
+
+- [Affects R1][Technical] What specific regex/keyword list for 400 body matching avoids false positives? Need to check real 400 responses from multiple APIs.
+- [Affects R9][Technical] Does helpers.go.tmpl currently receive `*spec.APISpec` directly or through `helpersTemplateData`? If the latter, `Auth.EnvVars` might not be accessible. Check generator.go.
+- [Affects R12][Technical] What regex patterns reliably extract key registration URLs from npm READMEs without false positives? Need to sample 5-10 real SDK READMEs.
+
+## Next Steps
+
+→ `/ce:plan` for structured implementation planning
diff --git a/docs/plans/2026-03-31-004-fix-auth-error-handling-plan.md b/docs/plans/2026-03-31-004-fix-auth-error-handling-plan.md
new file mode 100644
index 00000000..0cef6517
--- /dev/null
+++ b/docs/plans/2026-03-31-004-fix-auth-error-handling-plan.md
@@ -0,0 +1,248 @@
+---
+title: "fix: Better auth error handling in generated CLIs"
+type: fix
+status: completed
+date: 2026-03-31
+origin: docs/brainstorms/2026-03-31-auth-error-handling-requirements.md
+---
+
+# fix: Better auth error handling in generated CLIs
+
+## Overview
+
+Generated CLIs give poor feedback when auth is missing or wrong. HTTP 400 responses for missing keys aren't recognized as auth errors. Error messages don't name the specific env var. Doctor doesn't tell you how to fix auth. This plan adds 400 body pattern matching, env-var-specific error messages, key URL discovery, and improved doctor output.
+
+## Problem Frame
+
+Steam returns HTTP 400 ("Required parameter 'key' is missing") for missing auth, not 401. The generator's `classifyAPIError` only catches 401/403, so the user sees a raw HTML error. Even for 401/403, the hint is generic ("check your credentials") instead of naming the env var. The doctor command says "Auth: not configured" without telling you which env var to set or where to get a key. (see origin: docs/brainstorms/2026-03-31-auth-error-handling-requirements.md)
+
+## Requirements Trace
+
+**Error Classification (400/401/403)**
+
+- R1. Pattern-match 400 bodies for auth keywords, treat as auth error
+- R4. Non-auth 400s not misclassified
+
+**Error Message Content**
+
+- R2. Auth errors include specific env var name (`export STEAM_API_KEY=...`)
+- R3. Auth errors include setup command
+- R13. Error messages include key URL when known
+- R14. Key URL omitted cleanly when unknown
+
+**Doctor Output**
+
+- R5. Doctor names the env var when auth not configured
+- R6. Doctor confirms auth source when configured
+
+**Auth Behavior**
+
+- R7. Per-call auth checking (no startup gate)
+- R8. No fail-fast startup gate
+
+**Pipeline: Spec Schema & Template Access**
+
+- R9. helpers.go.tmpl accesses `{{.Auth.EnvVars}}`
+- R10. doctor.go.tmpl accesses `{{.Auth.EnvVars}}`
+- R11. Add `key_url` (YAML field) / `KeyURL` (Go struct field) to `spec.AuthConfig`
+- R12. Crowd-sniff extracts key URLs from SDK READMEs (best-effort; `KeyURLHint` may be empty for APIs without discoverable key URLs)
+
+## Scope Boundaries
+
+- No OAuth flow improvements
+- No per-endpoint auth metadata
+- No interactive setup wizard
+- Key URL extraction is best-effort from SDK READMEs
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- **helpers.go.tmpl** receives `helpersTemplateData` which embeds `*spec.APISpec` — so `.Auth.EnvVars`, `.Auth.Type`, `.Auth.In`, `.Name` are all accessible. Already branches on `.Auth.Type` for 401 hints.
+- **doctor.go.tmpl** receives `*spec.APISpec` directly — full `.Auth.*` access. Already checks env vars via `{{range .Auth.EnvVars}}` + `os.Getenv`.
+- **classifyAPIError** uses `strings.Contains(msg, "HTTP NNN")` for status matching. 400 is not currently handled.
+- **spec.AuthConfig** has no `KeyURL` field. Need to add one.
+- **crowd-sniff DiscoveredAuth** has no `KeyURLHint` field. Need to add one.
+- **npm.go** tarball walker filters to `.js`/`.ts`/`.mjs` only — README.md is skipped. Key URL extraction needs a separate targeted README read, not an extension of `GrepAuth`.
+
+### Institutional Learnings
+
+- `classifyAPIError` string matching depends on client.go formatting errors as `"HTTP NNN"` — confirmed this pattern is consistent across all client templates.
+
+## Key Technical Decisions
+
+- **400 body matching: template-gated, body-keyword-triggered**: The 400 auth handler is only *emitted* in CLIs whose spec declares auth (`.Auth.Type != "none"` at template time). At runtime, `classifyAPIError` only sees the error string — it has no config access. When the error body matches auth keywords, it classifies as auth and shows the hint. This is correct for both missing keys (user forgot to set env var) and wrong keys (API rejects the key with a 400). The hint is useful in both cases. False positives on validation-only 400s are mitigated by the keyword regex targeting auth-specific terms.
+- **Template-time embedding**: Env var names and key URLs are embedded as string literals in the generated code at template rendering time, not resolved at runtime. Zero runtime cost, always correct.
+- **helpers.go.tmpl already has the right data**: It receives `helpersTemplateData` which embeds `*spec.APISpec`. No generator.go changes needed for data routing — just use `{{.Auth.EnvVars}}` and `{{.Auth.KeyURL}}` in the template.
+- **Key URL in spec, not config**: The key URL is a property of the API (where to register), not the user's config. It belongs in `spec.AuthConfig`, not `config.Config`.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Q: Does helpers.go.tmpl have access to Auth.EnvVars?** Yes — it receives `helpersTemplateData` which embeds `*spec.APISpec`. `.Auth.EnvVars` works directly.
+- **Q: What regex for 400 body matching?** Case-insensitive match for auth keywords. The 400 branch is template-gated (only emitted when spec declares auth). At runtime, body keyword match alone triggers the hint — useful for both missing and wrong keys. No runtime credential check needed.
+
+### Deferred to Implementation
+
+- **Q: Exact key URL extraction regex for SDK READMEs** — Need to sample real READMEs to calibrate. Start with patterns like `Get.*(?:API|key).*at\s+(https?://\S+)` and `(?:register|sign.up|developer).*(?:at|:)\s+(https?://\S+)`.
+
+## Implementation Units
+
+- [ ] **Unit 1: Add KeyURL field to spec and crowd-sniff types**
+
+  **Goal:** Add `key_url` to `spec.AuthConfig` and `KeyURLHint` to `DiscoveredAuth`, wire through specgen.
+
+  **Requirements:** R11
+
+  **Dependencies:** None
+
+  **Files:**
+  - Modify: `internal/spec/spec.go`
+  - Modify: `internal/crowdsniff/types.go`
+  - Modify: `internal/crowdsniff/specgen.go`
+  - Test: `internal/crowdsniff/specgen_test.go`
+
+  **Approach:**
+  - Add `KeyURL string \`yaml:"key_url,omitempty" json:"key_url,omitempty"\`` to `AuthConfig`
+  - Add `KeyURLHint string` to `DiscoveredAuth`
+  - In `buildAuthConfig()` in specgen.go, populate `spec.Auth.KeyURL` from `DiscoveredAuth.KeyURLHint` when non-empty
+
+  **Patterns to follow:**
+  - How `EnvVarHint` flows from `DiscoveredAuth` through `buildAuthConfig` to `spec.AuthConfig.EnvVars`
+
+  **Test scenarios:**
+  - Happy path: DiscoveredAuth with KeyURLHint → spec.Auth.KeyURL populated
+  - Edge case: Empty KeyURLHint → spec.Auth.KeyURL stays empty
+  - Negative test: Existing specs without key_url still parse correctly
+
+  **Verification:**
+  - `go build ./...` and `go test ./internal/spec/... ./internal/crowdsniff/...` pass
+
+- [ ] **Unit 2: Extract key URLs from npm SDK READMEs**
+
+  **Goal:** Crowd-sniff detects "get your API key at [url]" patterns in SDK source/READMEs.
+
+  **Requirements:** R12
+
+  **Dependencies:** Unit 1
+
+  **Files:**
+  - Modify: `internal/crowdsniff/npm.go`
+  - Test: `internal/crowdsniff/npm_test.go`
+
+  **Approach:**
+  - **Important:** The npm tarball walker (`npm.go:342-344`) currently filters to `.js`/`.ts`/`.mjs` only — README.md is skipped. Add a targeted README read: after the existing `filepath.Walk`, check for `package/README.md` (npm tarballs always have README at the package root), read it into a separate buffer, and pass it to a new `extractKeyURL(readmeContent, apiName)` function. Do NOT feed markdown into `GrepEndpoints` or `EnrichWithParams`.
+  - Add key URL regex patterns targeting auth setup phrases: "Get your API key at [url]", "Register at [url]", "Sign up at [url]", developer portal links
+  - Populate `DiscoveredAuth.KeyURLHint` when a URL is found
+  - URL must be HTTPS and must either contain the API name OR be on a well-known developer platform domain. Accept URLs from any package that passes the existing crowd-sniff relevance filter (not just official-tier)
+  - **ReDoS protection:** Avoid nested `.*` quantifiers in regex patterns. Use possessive quantifiers or bounded repetition. Apply a size limit (100KB) on README content before regex matching
+
+  **Patterns to follow:**
+  - `envVarHintPattern` extraction in `GrepAuth` — same approach of regex + relevance filtering
+
+  **Test scenarios:**
+  - Happy path: README with "Get your API key at https://steamcommunity.com/dev/apikey" → KeyURLHint extracted
+  - Happy path: README with "Register at https://developer.notion.com" → KeyURLHint extracted
+  - Edge case: README with no key URL patterns → KeyURLHint empty
+  - Edge case: README with irrelevant URLs (npm badge, GitHub link) → not extracted
+  - Negative test: Source file (not README) with URL → not extracted (only READMEs)
+
+  **Verification:**
+  - `go test ./internal/crowdsniff/...` passes
+
+- [ ] **Unit 3: Improve classifyAPIError with 400 handling and env-var-specific messages**
+
+  **Goal:** Auth errors (400/401/403) show the specific env var name, setup command, and key URL.
+
+  **Requirements:** R1, R2, R3, R4, R9, R13, R14
+
+  **Dependencies:** Unit 1 (KeyURL field must exist in spec)
+
+  **Files:**
+  - Modify: `internal/generator/templates/helpers.go.tmpl`
+  - Modify: `internal/generator/templates/mcp_tools.go.tmpl` (has a parallel inline error handler that must stay consistent)
+  - Test: `internal/generator/generator_test.go`
+
+  **Approach:**
+  - Add HTTP 400 handling to `classifyAPIError`: pattern-match the error body for auth keywords using case-insensitive regex. Only emit the 400 auth branch when the spec declares auth (`.Auth.Type != "none"`). At runtime, classify 400+auth-keywords as auth — useful for both missing and wrong keys.
+  - **Response body sanitization:** Truncate the raw error body to 200 chars before including in hints. Strip strings matching credential patterns (`sk-`, `sk_live_`, `Bearer `, `key=`) to prevent partial credential leakage from API error responses.
+  - Update the parallel error handler in `mcp_tools.go.tmpl` to match — either refactor to call `classifyAPIError` or apply the same 400 handling and improved messages inline.
+  - Rewrite all auth error hints (400/401/403) to include:
+    - The specific env var: `{{if .Auth.EnvVars}}export {{index .Auth.EnvVars 0}}=<your-key>{{end}}`
+    - The key URL when known: `{{if .Auth.KeyURL}}Get a key at: {{.Auth.KeyURL}}{{end}}`
+    - The doctor command: `Run '{{.Name}}-pp-cli doctor' to check auth status`
+  - Keep the existing auth-type branching (oauth2/bearer/api_key) but enrich each branch with the env var and URL
+
+  **Patterns to follow:**
+  - Existing 401 branch in helpers.go.tmpl — extends with env var name and key URL
+  - Conditional template emission: `{{if .Auth.KeyURL}}...{{end}}`
+
+  **Test scenarios:**
+  - Happy path: Generate CLI with api_key auth + env vars + key URL → error message contains env var name, export command, and key URL
+  - Happy path: Generate CLI with api_key auth + env vars, no key URL → error message contains env var but no URL line
+  - Happy path: Generate CLI with bearer_token auth → error message mentions token setup
+  - Edge case: Generate CLI with no auth → classifyAPIError doesn't mention auth setup at all
+  - Edge case: HTTP 400 with body "Required parameter 'key' is missing" → classified as auth error
+  - Edge case: HTTP 400 with body "Invalid email format" → NOT classified as auth error (no auth keywords)
+  - Negative test: HTTP 400 with auth keywords but auth IS configured → still shows hint (key might be wrong)
+
+  **Verification:**
+  - Generated helpers.go contains env var name in auth error messages
+  - Generated helpers.go handles HTTP 400 with body matching
+
+- [ ] **Unit 4: Improve doctor output with auth setup instructions**
+
+  **Goal:** Doctor command shows specific env var name and key URL when auth is not configured.
+
+  **Requirements:** R5, R6, R10, R13, R14
+
+  **Dependencies:** Unit 1 (KeyURL field)
+
+  **Files:**
+  - Modify: `internal/generator/templates/doctor.go.tmpl`
+  - Test: `internal/generator/generator_test.go`
+
+  **Approach:**
+  - When auth is not configured: show `Auth: not configured. Set {{index .Auth.EnvVars 0}} in your environment: export {{index .Auth.EnvVars 0}}=<your-key>`
+  - When key URL is known: add `Get a key at: {{.Auth.KeyURL}}`
+  - When auth IS configured: show `Auth: configured ({{cfg.AuthSource}})`
+  - doctor.go.tmpl already receives `*spec.APISpec` directly — `.Auth.EnvVars` and `.Auth.KeyURL` are accessible
+
+  **Patterns to follow:**
+  - Existing env var check section in doctor.go.tmpl (search for `range .Auth.EnvVars` or `os.Getenv` to locate)
+
+  **Test scenarios:**
+  - Happy path: Generate CLI with auth + env vars + key URL → doctor output includes env var name and key URL
+  - Happy path: Generate CLI with auth + env vars, no key URL → doctor shows env var but no URL
+  - Edge case: Generate CLI with no auth → doctor auth section shows "not required"
+
+  **Verification:**
+  - Generated doctor.go shows specific env var name and key URL in auth status
+
+## System-Wide Impact
+
+- **Generated CLI error messages** change for every API with auth configured — this affects all future generated CLIs
+- **Spec format** gains `key_url` field — backward compatible (omitempty), existing specs work unchanged
+- **Crowd-sniff output** gains key URL hints — additive, no breaking change
+- **Unchanged invariants**: Auth flow itself (how keys are passed to API) is unchanged. Only the error messages and doctor output change.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| 400 body matching false positives | Auth-keyword regex targets specific terms (key, token, unauthorized). Only emitted when spec declares auth. Validation-only 400s won't match. |
+| Key URL from malicious/typosquatted npm package | URL must be HTTPS + contain API name or known developer domain. Extracted from packages passing crowd-sniff relevance filter. Provenance logged for skill-time audit. |
+| API 400 response leaks partial credentials in error output | Response body truncated to 200 chars. Credential-shaped strings (sk-, Bearer, key=) stripped before display. |
+| README regex ReDoS on crafted input | Avoid nested `.*` quantifiers. Cap README processing at 100KB. |
+| Template change breaks existing generated CLIs | All changes are in templates — only affects newly generated CLIs. Existing binaries unchanged. |
+| MCP error handler diverges from CLI error handler | Unit 3 updates both helpers.go.tmpl and mcp_tools.go.tmpl to keep behavior consistent. |
+
+## Sources & References
+
+- **Origin document:** [docs/brainstorms/2026-03-31-auth-error-handling-requirements.md](docs/brainstorms/2026-03-31-auth-error-handling-requirements.md)
+- helpers.go.tmpl: `internal/generator/templates/helpers.go.tmpl` (classifyAPIError at lines 98-138)
+- doctor.go.tmpl: `internal/generator/templates/doctor.go.tmpl`
+- spec.AuthConfig: `internal/spec/spec.go:27-37`
+- crowd-sniff auth: `internal/crowdsniff/npm.go` (GrepAuth), `internal/crowdsniff/types.go` (DiscoveredAuth)
+- Steam retro: `docs/retros/2026-03-30-steam-retro.md`
diff --git a/internal/crowdsniff/npm.go b/internal/crowdsniff/npm.go
index b132b777..4fac55bd 100644
--- a/internal/crowdsniff/npm.go
+++ b/internal/crowdsniff/npm.go
@@ -25,6 +25,7 @@ const (
 	maxSearchResults        = 25
 	maxPackagesToProcess    = 10
 	maxBulkDownloadPackages = 128
+	maxReadmeSize           = 100 * 1024 // 100 KB
 )
 
 // NPMOptions configures the NPM source.
@@ -372,6 +373,19 @@ func (s *NPMSource) processPackageTarball(ctx context.Context, tarballURL, pkgNa
 	// Extract auth patterns from the combined source content.
 	authPatterns := GrepAuth(allContent.String(), tier, apiName)
 
+	// Read README.md from the package root for key URL hints.
+	// npm tarballs always have package/README.md.
+	readmePath := filepath.Join(tmpDir, "package", "README.md")
+	if readmeContent, readErr := readFileCapped(readmePath, maxReadmeSize); readErr == nil && len(readmeContent) > 0 {
+		if keyURL := extractKeyURL(string(readmeContent), apiName); keyURL != "" {
+			for i := range authPatterns {
+				if authPatterns[i].KeyURLHint == "" {
+					authPatterns[i].KeyURLHint = keyURL
+				}
+			}
+		}
+	}
+
 	return allEndpoints, allBaseURLs, authPatterns, nil
 }
 
@@ -608,3 +622,158 @@ func extractEnvVarHint(content string, apiName string) string {
 	// No API-name-relevant match — return empty to let deriveEnvVar() handle it.
 	return ""
 }
+
+// --- README key URL extraction ---
+
+var (
+	// keyURLPattern matches phrases that point to an API key registration page.
+	// Captures the HTTPS URL following the phrase.
+	// Uses [^")\s]+ for URL capture to avoid nested .* backtracking.
+	keyURLPattern = regexp.MustCompile(`(?i)(?:get\s+(?:your\s+)?(?:api\s+)?key\s+(?:at|from)|register\s+at|sign\s*up\s+at|get\s+(?:an?\s+)?api\s+key\s+(?:at|from)|create\s+(?:your\s+)?(?:api\s+)?key\s+at|developer\s+portal\s*:)\s*(https://[^")\s]+)`)
+
+	// markdownLinkKeyURLPattern matches markdown links with key-related anchor text.
+	// E.g., [Get your API key](https://example.com/keys)
+	markdownLinkKeyURLPattern = regexp.MustCompile(`(?i)\[(?:[^\]]*(?:api\s*key|register|sign\s*up|developer\s+portal|get\s+(?:your\s+)?key)[^\]]*)\]\((https://[^)"\s]+)\)`)
+
+	// badgeURLDomains are domains used for npm badges — never useful as key URLs.
+	badgeURLDomains = []string{
+		"img.shields.io",
+		"badge.fury.io",
+		"badges.greenkeeper.io",
+		"david-dm.org",
+		"codecov.io",
+		"coveralls.io",
+		"travis-ci.org",
+		"circleci.com",
+		"github.com/workflows",
+	}
+
+	// knownDevPlatformPrefixes are domain prefixes for well-known developer platforms.
+	knownDevPlatformPrefixes = []string{
+		"developer.",
+		"console.cloud.google.com",
+		"console.aws.amazon.com",
+		"dash.cloudflare.com",
+		"portal.azure.com",
+		"app.posthog.com",
+	}
+)
+
+// extractKeyURL scans README content for patterns like "Get your API key at [url]"
+// and returns the first HTTPS URL that either contains the API name or is on a
+// known developer platform domain. Returns empty string if no suitable URL is found.
+func extractKeyURL(readmeContent string, apiName string) string {
+	apiNameLower := strings.ToLower(apiName)
+
+	var candidates []string
+
+	// Collect URLs from plain-text patterns.
+	for _, m := range keyURLPattern.FindAllStringSubmatch(readmeContent, -1) {
+		if len(m) > 1 {
+			candidates = append(candidates, strings.TrimRight(m[1], ".,;:"))
+		}
+	}
+
+	// Collect URLs from markdown link patterns.
+	for _, m := range markdownLinkKeyURLPattern.FindAllStringSubmatch(readmeContent, -1) {
+		if len(m) > 1 {
+			candidates = append(candidates, strings.TrimRight(m[1], ".,;:"))
+		}
+	}
+
+	for _, rawURL := range candidates {
+		if !strings.HasPrefix(rawURL, "https://") {
+			continue
+		}
+
+		parsed, err := url.Parse(rawURL)
+		if err != nil {
+			continue
+		}
+		host := strings.ToLower(parsed.Hostname())
+
+		// Reject badge URLs.
+		if isBadgeURL(host) {
+			continue
+		}
+
+		// Reject GitHub repo URLs that are just the package's own repo
+		// (github.com/<owner>/<repo> without /wiki, /blob, etc. pointing to docs).
+		if isPackageRepoURL(host, parsed.Path) {
+			continue
+		}
+
+		// Accept if the URL contains the API name (case-insensitive).
+		if apiNameLower != "" && strings.Contains(strings.ToLower(rawURL), apiNameLower) {
+			return rawURL
+		}
+
+		// Accept if on a known developer platform domain AND the URL/host contains the API name.
+		// This prevents developer.evil.com from being accepted without API name relevance.
+		if isKnownDevPlatform(host) && apiNameLower != "" && strings.Contains(strings.ToLower(rawURL), apiNameLower) {
+			return rawURL
+		}
+	}
+
+	return ""
+}
+
+// isBadgeURL returns true if the host is a known npm badge domain.
+func isBadgeURL(host string) bool {
+	for _, badge := range badgeURLDomains {
+		if host == badge || strings.HasSuffix(host, "."+badge) {
+			return true
+		}
+	}
+	return false
+}
+
+// isPackageRepoURL returns true if this looks like a plain GitHub repo URL
+// (github.com/<owner>/<repo>) without a deeper path that might point to
+// documentation or key pages.
+func isPackageRepoURL(host, path string) bool {
+	if host != "github.com" {
+		return false
+	}
+	// Strip trailing slash and count path segments.
+	trimmed := strings.Trim(path, "/")
+	if trimmed == "" {
+		return false
+	}
+	parts := strings.Split(trimmed, "/")
+	// github.com/<owner>/<repo> (2 segments) is just the repo root — reject.
+	// github.com/<owner>/<repo>/blob/... or /wiki/... may contain docs — allow.
+	return len(parts) <= 2
+}
+
+// isKnownDevPlatform returns true if the host matches a well-known developer platform.
+func isKnownDevPlatform(host string) bool {
+	for _, prefix := range knownDevPlatformPrefixes {
+		if strings.HasPrefix(host, prefix) || host == prefix {
+			return true
+		}
+	}
+	return false
+}
+
+// readFileCapped reads up to maxBytes from a file. Returns the content and any error.
+// If the file is larger than maxBytes, the content is truncated (no error).
+func readFileCapped(path string, maxBytes int) ([]byte, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	defer func() { _ = f.Close() }()
+
+	buf := make([]byte, maxBytes)
+	n, err := io.ReadFull(f, buf)
+	if err == io.ErrUnexpectedEOF || err == io.EOF {
+		// File was smaller than maxBytes — that's fine.
+		return buf[:n], nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	// File was exactly maxBytes or larger — return the capped content.
+	return buf[:n], nil
+}
diff --git a/internal/crowdsniff/npm_test.go b/internal/crowdsniff/npm_test.go
index 51090228..0bc62ff8 100644
--- a/internal/crowdsniff/npm_test.go
+++ b/internal/crowdsniff/npm_test.go
@@ -9,6 +9,8 @@ import (
 	"fmt"
 	"net/http"
 	"net/http/httptest"
+	"os"
+	"strings"
 	"testing"
 	"time"
 
@@ -1426,4 +1428,269 @@ class API {
 		require.NoError(t, err)
 		assert.Empty(t, authPatterns)
 	})
+
+	t.Run("extracts key URL from README into auth KeyURLHint", func(t *testing.T) {
+		t.Parallel()
+
+		sdkContent := `
+class SteamAPI {
+  constructor(apiKey) { this.apiKey = apiKey; }
+  getOwnedGames(steamid) {
+    return this.get("/IPlayerService/GetOwnedGames/v1", {
+      key: this.apiKey,
+      steamid: steamid
+    });
+  }
+}
+`
+		readmeContent := `# Steam SDK
+
+Get your API key at https://steamcommunity.com/dev/apikey
+
+## Usage
+...
+`
+		tarball := buildTarball(t, map[string]string{
+			"package/src/client.js": sdkContent,
+			"package/README.md":     readmeContent,
+		})
+
+		tarballServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Write(tarball)
+		}))
+		defer tarballServer.Close()
+
+		src := NewNPMSource(NPMOptions{
+			HTTPClient: tarballServer.Client(),
+		})
+
+		_, _, authPatterns, err := src.processPackageTarball(
+			context.Background(),
+			tarballServer.URL+"/tarball.tgz",
+			"steam-sdk",
+			TierCommunitySDK,
+			"steam",
+			500,
+		)
+
+		require.NoError(t, err)
+		require.NotEmpty(t, authPatterns)
+		assert.Equal(t, "https://steamcommunity.com/dev/apikey", authPatterns[0].KeyURLHint)
+	})
+
+	t.Run("no README in tarball does not error", func(t *testing.T) {
+		t.Parallel()
+
+		sdkContent := `
+class API {
+  request(url) {
+    return fetch(url + '?key=' + this.apiKey);
+  }
+}
+`
+		tarball := buildTarball(t, map[string]string{
+			"package/src/client.js": sdkContent,
+		})
+
+		tarballServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Write(tarball)
+		}))
+		defer tarballServer.Close()
+
+		src := NewNPMSource(NPMOptions{
+			HTTPClient: tarballServer.Client(),
+		})
+
+		_, _, authPatterns, err := src.processPackageTarball(
+			context.Background(),
+			tarballServer.URL+"/tarball.tgz",
+			"test-sdk",
+			TierCommunitySDK,
+			"testapi",
+			0,
+		)
+
+		require.NoError(t, err)
+		// Auth patterns may or may not be found, but no crash.
+		_ = authPatterns
+	})
+}
+
+func TestExtractKeyURL(t *testing.T) {
+	t.Parallel()
+
+	t.Run("extracts steam API key URL", func(t *testing.T) {
+		t.Parallel()
+
+		readme := `# Steam SDK
+Get your API key at https://steamcommunity.com/dev/apikey
+
+## Usage
+...`
+		url := extractKeyURL(readme, "steam")
+		assert.Equal(t, "https://steamcommunity.com/dev/apikey", url)
+	})
+
+	t.Run("extracts register at developer URL", func(t *testing.T) {
+		t.Parallel()
+
+		readme := `# Notion Client
+Register at https://developer.notion.com to get started.
+`
+		url := extractKeyURL(readme, "notion")
+		assert.Equal(t, "https://developer.notion.com", url)
+	})
+
+	t.Run("no key URL patterns returns empty", func(t *testing.T) {
+		t.Parallel()
+
+		readme := `# Some SDK
+This is a great library for interacting with things.
+
+## Installation
+npm install some-sdk
+`
+		url := extractKeyURL(readme, "some")
+		assert.Empty(t, url)
+	})
+
+	t.Run("rejects npm badge URLs", func(t *testing.T) {
+		t.Parallel()
+
+		readme := `# Badge SDK
+Get your API key at https://img.shields.io/badge/api-key-blue
+`
+		url := extractKeyURL(readme, "badge")
+		assert.Empty(t, url)
+	})
+
+	t.Run("large README is truncated without crash", func(t *testing.T) {
+		t.Parallel()
+
+		// Build a README > 100KB with a key URL at the start.
+		large := "Get your API key at https://developer.example.com/keys\n"
+		large += strings.Repeat("x", 150*1024) // 150KB of padding
+		url := extractKeyURL(large, "example")
+		assert.Equal(t, "https://developer.example.com/keys", url)
+	})
+
+	t.Run("extracts markdown link with key text", func(t *testing.T) {
+		t.Parallel()
+
+		readme := `# Notion Client
+Visit the [developer portal](https://developer.notion.com/keys) to get started.
+`
+		url := extractKeyURL(readme, "notion")
+		assert.Equal(t, "https://developer.notion.com/keys", url)
+	})
+
+	t.Run("sign up at pattern", func(t *testing.T) {
+		t.Parallel()
+
+		readme := `# Stripe SDK
+Sign up at https://dashboard.stripe.com/apikeys to get your key.
+`
+		url := extractKeyURL(readme, "stripe")
+		assert.Equal(t, "https://dashboard.stripe.com/apikeys", url)
+	})
+
+	t.Run("rejects plain GitHub repo URL", func(t *testing.T) {
+		t.Parallel()
+
+		readme := `# SDK
+Get your API key at https://github.com/owner/repo
+`
+		url := extractKeyURL(readme, "owner")
+		assert.Empty(t, url)
+	})
+
+	t.Run("accepts GitHub URL with deeper path", func(t *testing.T) {
+		t.Parallel()
+
+		readme := `# SDK
+Get your API key at https://github.com/owner/repo/wiki/api-keys
+`
+		url := extractKeyURL(readme, "owner")
+		assert.Equal(t, "https://github.com/owner/repo/wiki/api-keys", url)
+	})
+
+	t.Run("accepts URL on known developer platform without API name", func(t *testing.T) {
+		t.Parallel()
+
+		readme := `# Cloud SDK
+Register at https://developer.example.com/keys to get your credentials.
+`
+		url := extractKeyURL(readme, "unrelated")
+		assert.Equal(t, "https://developer.example.com/keys", url)
+	})
+
+	t.Run("rejects URL that matches neither API name nor known platform", func(t *testing.T) {
+		t.Parallel()
+
+		readme := `# SDK
+Get your API key at https://random-site.example.org/keys
+`
+		url := extractKeyURL(readme, "unrelated")
+		assert.Empty(t, url)
+	})
+
+	t.Run("URL with trailing punctuation is cleaned", func(t *testing.T) {
+		t.Parallel()
+
+		readme := `Get your API key at https://steamcommunity.com/dev/apikey.`
+		url := extractKeyURL(readme, "steam")
+		assert.Equal(t, "https://steamcommunity.com/dev/apikey", url)
+	})
+
+	t.Run("create your key at pattern", func(t *testing.T) {
+		t.Parallel()
+
+		readme := `Create your API key at https://developer.spotify.com/dashboard`
+		url := extractKeyURL(readme, "spotify")
+		assert.Equal(t, "https://developer.spotify.com/dashboard", url)
+	})
+
+	t.Run("markdown link with get your API key anchor", func(t *testing.T) {
+		t.Parallel()
+
+		readme := `[Get your API key](https://developer.notion.com/keys)`
+		url := extractKeyURL(readme, "notion")
+		assert.Equal(t, "https://developer.notion.com/keys", url)
+	})
+}
+
+func TestReadFileCapped(t *testing.T) {
+	t.Parallel()
+
+	t.Run("reads file smaller than cap", func(t *testing.T) {
+		t.Parallel()
+
+		dir := t.TempDir()
+		path := dir + "/small.txt"
+		require.NoError(t, os.WriteFile(path, []byte("hello"), 0o644))
+
+		content, err := readFileCapped(path, 1024)
+		require.NoError(t, err)
+		assert.Equal(t, "hello", string(content))
+	})
+
+	t.Run("truncates file larger than cap", func(t *testing.T) {
+		t.Parallel()
+
+		dir := t.TempDir()
+		path := dir + "/large.txt"
+		data := strings.Repeat("a", 200)
+		require.NoError(t, os.WriteFile(path, []byte(data), 0o644))
+
+		content, err := readFileCapped(path, 100)
+		require.NoError(t, err)
+		assert.Len(t, content, 100)
+	})
+
+	t.Run("missing file returns error", func(t *testing.T) {
+		t.Parallel()
+
+		_, err := readFileCapped("/nonexistent/file.txt", 1024)
+		assert.Error(t, err)
+	})
 }
diff --git a/internal/crowdsniff/specgen.go b/internal/crowdsniff/specgen.go
index 6ef4ee9a..d607c4ff 100644
--- a/internal/crowdsniff/specgen.go
+++ b/internal/crowdsniff/specgen.go
@@ -211,6 +211,10 @@ func buildAuthConfig(apiName string, auth *DiscoveredAuth) spec.AuthConfig {
 		cfg.EnvVars = []string{envVar}
 	}
 
+	if auth.KeyURLHint != "" {
+		cfg.KeyURL = auth.KeyURLHint
+	}
+
 	return cfg
 }
 
diff --git a/internal/crowdsniff/specgen_test.go b/internal/crowdsniff/specgen_test.go
index 4d63678a..3fec1b12 100644
--- a/internal/crowdsniff/specgen_test.go
+++ b/internal/crowdsniff/specgen_test.go
@@ -102,6 +102,109 @@ func TestBuildSpec(t *testing.T) {
 	})
 }
 
+func TestBuildSpec_KeyURL(t *testing.T) {
+	t.Parallel()
+
+	endpoints := []AggregatedEndpoint{
+		{Method: "GET", Path: "/v1/users", SourceTier: TierOfficialSDK, SourceCount: 1},
+	}
+
+	t.Run("KeyURLHint propagates to spec.Auth.KeyURL", func(t *testing.T) {
+		t.Parallel()
+
+		auth := &DiscoveredAuth{
+			Type:       "api_key",
+			Header:     "X-Api-Key",
+			In:         "header",
+			EnvVarHint: "EXAMPLE_API_KEY",
+			KeyURLHint: "https://example.com/developers/keys",
+			SourceTier: TierOfficialSDK,
+		}
+
+		apiSpec, err := BuildSpec("example", "https://api.example.com", endpoints, auth)
+		require.NoError(t, err)
+
+		assert.Equal(t, "https://example.com/developers/keys", apiSpec.Auth.KeyURL)
+	})
+
+	t.Run("empty KeyURLHint leaves spec.Auth.KeyURL empty", func(t *testing.T) {
+		t.Parallel()
+
+		auth := &DiscoveredAuth{
+			Type:       "bearer_token",
+			Header:     "Authorization",
+			In:         "header",
+			Format:     "Bearer {token}",
+			SourceTier: TierOfficialSDK,
+		}
+
+		apiSpec, err := BuildSpec("example", "https://api.example.com", endpoints, auth)
+		require.NoError(t, err)
+
+		assert.Empty(t, apiSpec.Auth.KeyURL)
+	})
+
+	t.Run("nil auth leaves spec.Auth.KeyURL empty", func(t *testing.T) {
+		t.Parallel()
+
+		apiSpec, err := BuildSpec("example", "https://api.example.com", endpoints, nil)
+		require.NoError(t, err)
+
+		assert.Empty(t, apiSpec.Auth.KeyURL)
+	})
+}
+
+func TestAuthConfig_KeyURL_RoundTrip(t *testing.T) {
+	t.Parallel()
+
+	// A spec without key_url should still parse correctly (backward compat).
+	yamlWithout := `
+name: test
+base_url: https://api.example.com
+auth:
+  type: api_key
+  header: X-Api-Key
+  format: "{api_key}"
+  env_vars:
+    - TEST_API_KEY
+resources:
+  users:
+    description: User operations
+    endpoints:
+      list:
+        method: GET
+        path: /users
+        description: List users
+`
+	specWithout, err := spec.ParseBytes([]byte(yamlWithout))
+	require.NoError(t, err)
+	assert.Empty(t, specWithout.Auth.KeyURL)
+
+	// A spec with key_url should round-trip correctly.
+	yamlWith := `
+name: test
+base_url: https://api.example.com
+auth:
+  type: api_key
+  header: X-Api-Key
+  format: "{api_key}"
+  env_vars:
+    - TEST_API_KEY
+  key_url: https://example.com/keys
+resources:
+  users:
+    description: User operations
+    endpoints:
+      list:
+        method: GET
+        path: /users
+        description: List users
+`
+	specWith, err := spec.ParseBytes([]byte(yamlWith))
+	require.NoError(t, err)
+	assert.Equal(t, "https://example.com/keys", specWith.Auth.KeyURL)
+}
+
 func TestResolveBaseURL(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/crowdsniff/types.go b/internal/crowdsniff/types.go
index 40fa95a2..a7c7b4c6 100644
--- a/internal/crowdsniff/types.go
+++ b/internal/crowdsniff/types.go
@@ -32,6 +32,7 @@ type DiscoveredAuth struct {
 	In         string // "header" or "query"
 	Format     string // e.g., "Bearer {token}", "{api_key}"
 	EnvVarHint string // detected env var name if visible (e.g., "STEAM_API_KEY")
+	KeyURLHint string // URL to get an API key, extracted from SDK README
 	SourceTier string // tier of the source that found this auth pattern
 }
 
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index fb8a0dd6..d7ddbe1a 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -830,6 +830,345 @@ func TestGeneratedOutput_PromotedCommandNotForBuiltins(t *testing.T) {
 	assert.NoFileExists(t, filepath.Join(outputDir, "internal", "cli", "promoted_users.go"))
 }
 
+// --- Unit 3: Auth Error Handling Tests ---
+
+func TestGeneratedHelpers_AuthErrorWithEnvVarsAndKeyURL(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "steamauth",
+		Version: "0.1.0",
+		BaseURL: "https://api.steampowered.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "key",
+			In:      "query",
+			EnvVars: []string{"STEAM_API_KEY"},
+			KeyURL:  "https://steamcommunity.com/dev/apikey",
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/steamauth-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"users": {
+				Description: "Manage users",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/users", Description: "List users"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "steamauth-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	helpersGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "helpers.go"))
+	require.NoError(t, err)
+	content := string(helpersGo)
+
+	// 400 auth branch should be emitted
+	assert.Contains(t, content, `HTTP 400`)
+	assert.Contains(t, content, "looksLikeAuthError")
+	// Env var should appear in error hints
+	assert.Contains(t, content, "STEAM_API_KEY")
+	// Key URL should appear in error hints
+	assert.Contains(t, content, "https://steamcommunity.com/dev/apikey")
+	// Doctor command hint
+	assert.Contains(t, content, "steamauth-pp-cli doctor")
+	// Sanitization helpers should be present
+	assert.Contains(t, content, "sanitizeErrorBody")
+}
+
+func TestGeneratedHelpers_AuthErrorWithEnvVarsNoKeyURL(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "nourlauth",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "Authorization",
+			Format:  "Bearer {token}",
+			EnvVars: []string{"NOURL_API_KEY"},
+			// KeyURL intentionally empty
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/nourlauth-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Description: "Manage items",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/items", Description: "List items"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "nourlauth-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	helpersGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "helpers.go"))
+	require.NoError(t, err)
+	content := string(helpersGo)
+
+	// Env var should appear
+	assert.Contains(t, content, "NOURL_API_KEY")
+	// Key URL should NOT appear
+	assert.NotContains(t, content, "Get a key at:")
+}
+
+func TestGeneratedHelpers_BearerTokenAuth(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "bearerauth",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "bearer_token",
+			Header:  "Authorization",
+			Format:  "Bearer {token}",
+			EnvVars: []string{"BEARER_TOKEN"},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/bearerauth-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Description: "Manage items",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/items", Description: "List items"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "bearerauth-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	helpersGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "helpers.go"))
+	require.NoError(t, err)
+	content := string(helpersGo)
+
+	// Bearer token hint should mention token setup
+	assert.Contains(t, content, "check your token")
+	assert.Contains(t, content, "BEARER_TOKEN")
+	// 400 auth branch should be present (bearer_token is auth)
+	assert.Contains(t, content, "looksLikeAuthError")
+}
+
+func TestGeneratedHelpers_NoAuth_No400Branch(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "noauthapi",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "",
+			EnvVars: nil,
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/noauthapi-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Description: "Manage items",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/items", Description: "List items"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "noauthapi-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	helpersGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "helpers.go"))
+	require.NoError(t, err)
+	content := string(helpersGo)
+
+	// Should NOT have 400 auth branch
+	assert.NotContains(t, content, "looksLikeAuthError")
+	assert.NotContains(t, content, "sanitizeErrorBody")
+	// Should NOT import regexp
+	assert.NotContains(t, content, `"regexp"`)
+	// classifyAPIError should still exist
+	assert.Contains(t, content, "classifyAPIError")
+}
+
+func TestGeneratedHelpers_AuthWithKeyURL_Compiles(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "compileauth",
+		Version: "0.1.0",
+		BaseURL: "https://api.steampowered.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "key",
+			In:      "query",
+			EnvVars: []string{"STEAM_API_KEY"},
+			KeyURL:  "https://steamcommunity.com/dev/apikey",
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/compileauth-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"users": {
+				Description: "Manage users",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/users", Description: "List users"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "compileauth-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	// Must compile
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
+}
+
+// --- Unit 4: Doctor Auth Hint Tests ---
+
+func TestGeneratedDoctor_AuthHintsWithKeyURL(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "steamdoc",
+		Version: "0.1.0",
+		BaseURL: "https://api.steampowered.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "key",
+			In:      "query",
+			EnvVars: []string{"STEAM_API_KEY"},
+			KeyURL:  "https://steamcommunity.com/dev/apikey",
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/steamdoc-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"users": {
+				Description: "Manage users",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/users", Description: "List users"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "steamdoc-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+	require.NoError(t, err)
+	content := string(doctorGo)
+
+	// Should contain the env var hint
+	assert.Contains(t, content, `export STEAM_API_KEY=<your-key>`)
+	// Should contain the key URL
+	assert.Contains(t, content, `https://steamcommunity.com/dev/apikey`)
+}
+
+func TestGeneratedDoctor_AuthHintsWithoutKeyURL(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "nourldoc",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "Authorization",
+			Format:  "Bearer {token}",
+			EnvVars: []string{"NOURL_API_KEY"},
+			// KeyURL intentionally empty
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/nourldoc-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Description: "Manage items",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/items", Description: "List items"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "nourldoc-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+	require.NoError(t, err)
+	content := string(doctorGo)
+
+	// Should contain the env var hint
+	assert.Contains(t, content, `export NOURL_API_KEY=<your-key>`)
+	// Should NOT contain any key URL line
+	assert.NotContains(t, content, "auth_key_url")
+}
+
+func TestGeneratedDoctor_NoAuthShowsNotRequired(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "noauthdoc",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "",
+			EnvVars: nil,
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/noauthdoc-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Description: "Manage items",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/items", Description: "List items"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "noauthdoc-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	doctorGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "doctor.go"))
+	require.NoError(t, err)
+	content := string(doctorGo)
+
+	// Auth report should show "not required" — not "not configured"
+	assert.Contains(t, content, `report["auth"] = "not required"`)
+	// The auth section should NOT set report["auth"] to "not configured"
+	assert.NotContains(t, content, `report["auth"] = "not configured"`)
+}
+
 func TestGeneratedHelpers_DeadCodeRemoved(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index 5c5d267f..c66db7ba 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -34,15 +34,25 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 			}
 
 			// Check auth
+{{- if or (eq .Auth.Type "") (eq .Auth.Type "none")}}
+			report["auth"] = "not required"
+{{- else}}
 			if cfg != nil {
 				header := cfg.AuthHeader()
 				if header == "" {
 					report["auth"] = "not configured"
+{{- if .Auth.EnvVars}}
+					report["auth_hint"] = "export {{index .Auth.EnvVars 0}}=<your-key>"
+{{- end}}
+{{- if .Auth.KeyURL}}
+					report["auth_key_url"] = "{{.Auth.KeyURL}}"
+{{- end}}
 				} else {
 					report["auth"] = "configured"
 					report["auth_source"] = cfg.AuthSource
 				}
 			}
+{{- end}}
 
 			// Check auth environment variables
 			{{- if .Auth.EnvVars}}
@@ -164,6 +174,17 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 					fmt.Fprintf(w, "  %s: %v\n", key, v)
 				}
 			}
+			// Print auth setup hints (indented under Auth line)
+{{- if .Auth.EnvVars}}
+			if hint, ok := report["auth_hint"]; ok {
+				fmt.Fprintf(w, "  hint: %v\n", hint)
+			}
+{{- end}}
+{{- if .Auth.KeyURL}}
+			if keyURL, ok := report["auth_key_url"]; ok {
+				fmt.Fprintf(w, "  Get a key at: %v\n", keyURL)
+			}
+{{- end}}
 			return nil
 		},
 	}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 4962ef6c..9e5a5364 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -9,6 +9,9 @@ import (
 	"fmt"
 	"io"
 	"os"
+{{- if and .Auth.Type (ne .Auth.Type "none")}}
+	"regexp"
+{{- end}}
 	"sort"
 	"strings"
 	"text/tabwriter"
@@ -95,6 +98,41 @@ func apiErr(err error) error      { return &cliError{code: 5, err: err} }
 func configErr(err error) error   { return &cliError{code: 10, err: err} }
 func rateLimitErr(err error) error { return &cliError{code: 7, err: err} }
 
+{{- if and .Auth.Type (ne .Auth.Type "none")}}
+// looksLikeAuthError checks if an error message body contains auth-related keywords.
+func looksLikeAuthError(msg string) bool {
+	lower := strings.ToLower(msg)
+	patterns := []string{
+		`\bkey\b`,
+		`\btoken\b`,
+		`\bunauthorized\b`,
+		`\bapi_key\b`,
+		`missing.{0,20}key`,
+		`required.{0,20}key`,
+		`\bforbidden\b`,
+		`\bauthenticat`,
+		`\bcredential`,
+	}
+	for _, p := range patterns {
+		if matched, _ := regexp.MatchString(p, lower); matched {
+			return true
+		}
+	}
+	return false
+}
+
+// sanitizeErrorBody truncates and strips credential-shaped strings from error output.
+func sanitizeErrorBody(msg string) string {
+	if len(msg) > 200 {
+		msg = msg[:200] + "..."
+	}
+	// Strip credential-shaped patterns
+	credPatterns := regexp.MustCompile(`(?i)(sk-[a-zA-Z0-9]{8,}|sk_live_[a-zA-Z0-9]+|Bearer\s+[a-zA-Z0-9._\-]+|key=[a-zA-Z0-9._\-]+)`)
+	msg = credPatterns.ReplaceAllString(msg, "[REDACTED]")
+	return msg
+}
+{{- end}}
+
 // classifyAPIError maps API errors to structured exit codes with actionable hints.
 func classifyAPIError(err error) error {
 	msg := err.Error()
@@ -103,30 +141,76 @@ func classifyAPIError(err error) error {
 		// 409 Conflict = resource already exists. For agents retrying creates, this is success.
 		fmt.Fprintln(os.Stderr, "already exists (no-op)")
 		return nil
+{{- if and .Auth.Type (ne .Auth.Type "none")}}
+	case strings.Contains(msg, "HTTP 400") && looksLikeAuthError(msg):
+		return authErr(fmt.Errorf("%w\nhint: the API rejected the request — this usually means auth is missing or invalid."+
+{{- if .Auth.EnvVars}}
+			"\n      Set your API key: export {{index .Auth.EnvVars 0}}=<your-key>"+
+{{- end}}
+{{- if .Auth.KeyURL}}
+			"\n      Get a key at: {{.Auth.KeyURL}}"+
+{{- end}}
+			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status."+
+			"\n      Response: "+sanitizeErrorBody(msg), err))
+{{- end}}
 	case strings.Contains(msg, "HTTP 401"):
 {{- if eq .Auth.Type "oauth2"}}
-		return authErr(fmt.Errorf("%w\nhint: your token may have expired. Re-run '{{.Name}}-pp-cli auth login' to re-authenticate", err))
+		return authErr(fmt.Errorf("%w\nhint: your token may have expired. Re-run '{{.Name}}-pp-cli auth login' to re-authenticate"+
+{{- if .Auth.EnvVars}}
+			"\n      or: export {{index .Auth.EnvVars 0}}=<your-token>"+
+{{- end}}
+{{- if .Auth.KeyURL}}
+			"\n      Get a key at: {{.Auth.KeyURL}}"+
+{{- end}}
+			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status.", err))
 {{- else if eq .Auth.Type "bearer_token"}}
 		return authErr(fmt.Errorf("%w\nhint: check your token. Set it with: {{.Name}}-pp-cli auth set-token <token>"+
 {{- if .Auth.EnvVars}}
-			"\n      or: export {{index .Auth.EnvVars 0}}=your-token"+
+			"\n      or: export {{index .Auth.EnvVars 0}}=<your-token>"+
+{{- end}}
+{{- if .Auth.KeyURL}}
+			"\n      Get a key at: {{.Auth.KeyURL}}"+
 {{- end}}
-			"\n      verify setup: {{.Name}}-pp-cli doctor", err))
+			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status.", err))
 {{- else if eq .Auth.Type "api_key"}}
 		return authErr(fmt.Errorf("%w\nhint: check your API key."+
 {{- if .Auth.EnvVars}}
-			" Set it with: export {{index .Auth.EnvVars 0}}=your-key"+
+			" Set it with: export {{index .Auth.EnvVars 0}}=<your-key>"+
 {{- end}}
-			"\n      verify setup: {{.Name}}-pp-cli doctor", err))
+{{- if .Auth.KeyURL}}
+			"\n      Get a key at: {{.Auth.KeyURL}}"+
+{{- end}}
+			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status.", err))
 {{- else}}
-		return authErr(fmt.Errorf("%w\nhint: check your API credentials. Run '{{.Name}}-pp-cli doctor' to verify auth", err))
+		return authErr(fmt.Errorf("%w\nhint: check your API credentials."+
+{{- if .Auth.EnvVars}}
+			" Set it with: export {{index .Auth.EnvVars 0}}=<your-key>"+
+{{- end}}
+{{- if .Auth.KeyURL}}
+			"\n      Get a key at: {{.Auth.KeyURL}}"+
+{{- end}}
+			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status.", err))
 {{- end}}
 	case strings.Contains(msg, "HTTP 403"):
 {{- if eq .Auth.Type "oauth2"}}
-		return authErr(fmt.Errorf("%w\nhint: permission denied. Your token may lack required scopes. Re-run '{{.Name}}-pp-cli auth login' to re-authorize", err))
+		return authErr(fmt.Errorf("%w\nhint: permission denied. Your token may lack required scopes. Re-run '{{.Name}}-pp-cli auth login' to re-authorize"+
+{{- if .Auth.EnvVars}}
+			"\n      or: export {{index .Auth.EnvVars 0}}=<your-token>"+
+{{- end}}
+{{- if .Auth.KeyURL}}
+			"\n      Get a key at: {{.Auth.KeyURL}}"+
+{{- end}}
+			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status.", err))
 {{- else}}
 		return authErr(fmt.Errorf("%w\nhint: permission denied. Your credentials are valid but lack access to this resource."+
-			"\n      Check that your API key has the required permissions", err))
+			"\n      Check that your API key has the required permissions."+
+{{- if .Auth.EnvVars}}
+			"\n      Set it with: export {{index .Auth.EnvVars 0}}=<your-key>"+
+{{- end}}
+{{- if .Auth.KeyURL}}
+			"\n      Get a key at: {{.Auth.KeyURL}}"+
+{{- end}}
+			"\n      Run '{{.Name}}-pp-cli doctor' to check auth status.", err))
 {{- end}}
 	case strings.Contains(msg, "HTTP 404"):
 		return notFoundErr(fmt.Errorf("%w\nhint: resource not found. Run the 'list' command to see available items", err))
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index 4defc890..1bd539d1 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -9,6 +9,9 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+{{- if and .Auth.Type (ne .Auth.Type "none")}}
+	"regexp"
+{{- end}}
 	"strings"
 	"time"
 
@@ -21,6 +24,40 @@ import (
 {{- end}}
 )
 
+{{- if and .Auth.Type (ne .Auth.Type "none")}}
+// looksLikeAuthError checks if an error message body contains auth-related keywords.
+func looksLikeAuthError(msg string) bool {
+	lower := strings.ToLower(msg)
+	patterns := []string{
+		`\bkey\b`,
+		`\btoken\b`,
+		`\bunauthorized\b`,
+		`\bapi_key\b`,
+		`missing.{0,20}key`,
+		`required.{0,20}key`,
+		`\bforbidden\b`,
+		`\bauthenticat`,
+		`\bcredential`,
+	}
+	for _, p := range patterns {
+		if matched, _ := regexp.MatchString(p, lower); matched {
+			return true
+		}
+	}
+	return false
+}
+
+// sanitizeErrorBody truncates and strips credential-shaped strings from error output.
+func sanitizeErrorBody(msg string) string {
+	if len(msg) > 200 {
+		msg = msg[:200] + "..."
+	}
+	credPatterns := regexp.MustCompile(`(?i)(sk-[a-zA-Z0-9]{8,}|sk_live_[a-zA-Z0-9]+|Bearer\s+[a-zA-Z0-9._\-]+|key=[a-zA-Z0-9._\-]+)`)
+	msg = credPatterns.ReplaceAllString(msg, "[REDACTED]")
+	return msg
+}
+{{- end}}
+
 // RegisterTools registers all API operations as MCP tools.
 func RegisterTools(s *server.MCPServer) {
 {{- range $name, $resource := .Resources}}
@@ -154,8 +191,50 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
 			switch {
 			case strings.Contains(msg, "HTTP 409"):
 				return mcplib.NewToolResultText("already exists (no-op)"), nil
-			case strings.Contains(msg, "HTTP 401") || strings.Contains(msg, "HTTP 403"):
-				return mcplib.NewToolResultError("authentication failed: " + msg + "\nhint: check API credentials"), nil
+{{- if and .Auth.Type (ne .Auth.Type "none")}}
+			case strings.Contains(msg, "HTTP 400") && looksLikeAuthError(msg):
+				return mcplib.NewToolResultError("authentication error: " + sanitizeErrorBody(msg) +
+					"\nhint: the API rejected the request — this usually means auth is missing or invalid." +
+{{- if .Auth.EnvVars}}
+					"\n      Set your API key: export {{index .Auth.EnvVars 0}}=<your-key>" +
+{{- end}}
+{{- if .Auth.KeyURL}}
+					"\n      Get a key at: {{.Auth.KeyURL}}" +
+{{- end}}
+					"\n      Run '{{.Name}}-pp-cli doctor' to check auth status."), nil
+{{- end}}
+			case strings.Contains(msg, "HTTP 401"):
+				return mcplib.NewToolResultError("authentication failed: " + {{if and .Auth.Type (ne .Auth.Type "none")}}sanitizeErrorBody(msg){{else}}msg{{end}} +
+{{- if eq .Auth.Type "oauth2"}}
+					"\nhint: your token may have expired. Re-run '{{.Name}}-pp-cli auth login' to re-authenticate" +
+{{- else if eq .Auth.Type "api_key"}}
+					"\nhint: check your API key." +
+{{- else if eq .Auth.Type "bearer_token"}}
+					"\nhint: check your token." +
+{{- else}}
+					"\nhint: check your API credentials." +
+{{- end}}
+{{- if .Auth.EnvVars}}
+					"\n      Set it with: export {{index .Auth.EnvVars 0}}=<your-key>" +
+{{- end}}
+{{- if .Auth.KeyURL}}
+					"\n      Get a key at: {{.Auth.KeyURL}}" +
+{{- end}}
+					"\n      Run '{{.Name}}-pp-cli doctor' to check auth status."), nil
+			case strings.Contains(msg, "HTTP 403"):
+				return mcplib.NewToolResultError("permission denied: " + {{if and .Auth.Type (ne .Auth.Type "none")}}sanitizeErrorBody(msg){{else}}msg{{end}} +
+{{- if eq .Auth.Type "oauth2"}}
+					"\nhint: your token may lack required scopes. Re-run '{{.Name}}-pp-cli auth login' to re-authorize" +
+{{- else}}
+					"\nhint: your credentials are valid but lack access to this resource." +
+{{- end}}
+{{- if .Auth.EnvVars}}
+					"\n      Set it with: export {{index .Auth.EnvVars 0}}=<your-key>" +
+{{- end}}
+{{- if .Auth.KeyURL}}
+					"\n      Get a key at: {{.Auth.KeyURL}}" +
+{{- end}}
+					"\n      Run '{{.Name}}-pp-cli doctor' to check auth status."), nil
 			case strings.Contains(msg, "HTTP 404"):
 				if method == "DELETE" {
 					return mcplib.NewToolResultText("already deleted (no-op)"), nil
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 8877f0a9..51f7ec36 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -29,8 +29,9 @@ type AuthConfig struct {
 	Header           string   `yaml:"header" json:"header"`
 	Format           string   `yaml:"format" json:"format"`
 	EnvVars          []string `yaml:"env_vars" json:"env_vars"`
-	Scheme           string   `yaml:"scheme,omitempty" json:"scheme,omitempty"` // OpenAPI security scheme name
-	In               string   `yaml:"in,omitempty" json:"in,omitempty"`         // header, query, cookie
+	Scheme           string   `yaml:"scheme,omitempty" json:"scheme,omitempty"`   // OpenAPI security scheme name
+	In               string   `yaml:"in,omitempty" json:"in,omitempty"`           // header, query, cookie
+	KeyURL           string   `yaml:"key_url,omitempty" json:"key_url,omitempty"` // URL where users can register for an API key
 	AuthorizationURL string   `yaml:"authorization_url,omitempty" json:"authorization_url,omitempty"`
 	TokenURL         string   `yaml:"token_url,omitempty" json:"token_url,omitempty"`
 	Scopes           []string `yaml:"scopes,omitempty" json:"scopes,omitempty"`

← bed2f97c feat(skills): add API reachability gate before generation (#  ·  back to Cli Printing Press  ·  fix(ci): auto-rebuild printing-press binary on worktree crea ebc132f6 →