A valid Claude Code OAuth token kept telling me it was only authorised for Claude Code. The absurd part was that it had come from Claude Code’s own OAuth flow.

The login had completed normally. PKCE succeeded, the access token had the expected sk-ant-oat prefix, the profile endpoint knew who I was and token refresh worked. Then the first inference request returned:

“This credential is only authorized for use with Claude Code.”

I was building a proof-of-concept coding agent which now lives in foundry. I wanted it to use my existing Claude subscription rather than an API key with separate metered billing. The OAuth exchange was clearly working; something about the inference request was failing a second, undocumented test.

Authentication bugs encourage a very particular form of tunnel vision. A credential error arrives, so you inspect the credential. When that looks sound, you inspect it again with more intensity.

Everything that looked like authentication

In my January test, the working implementations used four beta flags:

const OAUTH_BETAS = [
  "oauth-2025-04-20",
  "interleaved-thinking-2025-05-14",
  "fine-grained-tool-streaming-2025-05-14",
  "context-management-2025-06-27",
]

They were present. Authorization: Bearer was well formed, x-api-key was absent, and the User-Agent matched Claude Code. The same token could retrieve my profile and refresh itself, so the server recognised both the credential and the account.

Changing the model did not explain this refusal. Haiku and Opus produced the same message.

I compared the request with three implementations that worked: the authentication plugin in Link Assistant’s agent, the opencode-anthropic-auth package and claude.js, the bundled official CLI. That comparison became claude-auth.md.

That comparison uncovered two other ways to produce the same misleading response. The working clients used the beta Messages route, /v1/messages?beta=true; the plain route could reject the credential. Some tokens also rejected the claude-code-20250219 beta flag, despite its name. Matching the route and removing that flag narrowed this particular investigation, but the error remained.

By then my request matched the official client everywhere I had thought to look. The remaining difference sat in the request body.

A painterly editorial collage for the Claude OAuth newline bug, showing credential flow, system prompt, and evidence cards.
Credential flow, system prompt, and evidence cards.

Fifty-seven characters, exactly

In my January test, the system field worked only when it contained this sentence:

You are Claude Code, Anthropic's official CLI for Claude.

It is fifty-seven characters long. With precisely that value, inference succeeded. Adding custom instructions—or one newline—brought the credential error back.

I did not keep the original HTTP captures, but the boundary was exact enough to fix the client: an OAuth request could use the Claude Code system sentence and nothing else in that field.

function getOauthSystemPrompt(systemPrompt: string): string {
  if (process.env.CODING_AGENT_OAUTH_SYSTEM_MODE === "full") {
    if (!systemPrompt) {
      return CLAUDE_CODE_SYSTEM_HEADER
    }
    if (systemPrompt.includes(CLAUDE_CODE_SYSTEM_HEADER)) {
      return systemPrompt
    }
    return `${CLAUDE_CODE_SYSTEM_HEADER}\n\n${systemPrompt}`
  }
  // OAuth tokens require the system prompt to be exactly the Claude Code header.
  return CLAUDE_CODE_SYSTEM_HEADER
}

In its default OAuth mode, src/provider/anthropic.ts returns the Claude Code header unmodified. A tool that needs its own system instructions has to put that guidance elsewhere or use an API key. The optional full mode remains useful for testing, but it crosses the boundary observed in January.

I have not found an explanation from Anthropic. My guess is that, because Claude Code OAuth tokens are attached to consumer subscriptions, pinning the system prompt limits how far another client can depart from the behaviour they were issued to support.

Once I stripped the system field back to those fifty-seven characters, the same credential worked. Login, account lookup and refresh had proved identity; they had not granted permission for an arbitrary Messages request.

Chris Chabot · January 2026