[BUG] Apps gateway serves Claude Desktop an `otlpEndpoint` pointing at its own bearer-gated OTLP ingest but no `otlpHeaders`, so every Desktop telemetry flush is rejected with `missing_token`

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 16 comments · opened Jul 28, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Summary

A Claude apps gateway with telemetry.forward_to configured and a desktop block in a managed policy serves Claude Desktop a GET /user/bootstrap response containing otlpEndpoint set to listen.public_url — the gateway's own origin — and no otlpHeaders.

The gateway's OTLP ingest endpoints require a bearer token (POST /v1/metrics, /v1/logs, /v1/traces, per the gateway protocol reference). otlpHeaders is the only mechanism Claude Desktop has for attaching credentials to its OTLP exports. Because bootstrap populates the endpoint but not the headers, Desktop posts unauthenticated to an endpoint that mandates authentication, and 100% of Desktop telemetry is dropped at the door.

No client-side or MDM setting can fix this: the credential the endpoint wants is a per-user device-flow token with (in our case) a 1-hour TTL, and MDM otlpHeaders values are necessarily static.

Claude Code CLI telemetry through the same gateway works correctly — it authenticates with its session bearer and the gateway relays to forward_to. Desktop is the only affected client.

Environment

  • Gateway server: claude 2.1.220, self-hosted (ECS Fargate behind an ALB)
  • Claude Desktop on Windows 11, managed via HKLM\SOFTWARE\Policies\Claude
  • Auth: OIDC, session.ttl_hours: 1
  • Upstream: Amazon Bedrock
  • Telemetry destination: OTLP/HTTP collector reached via telemetry.forward_to

Notes / supporting detail

  • The gateway protocol reference documents the telemetry endpoints as POST /v1/metrics, /v1/logs, /v1/traces (bearer), and states that when connected to a gateway the client sends telemetry there and ignores OTEL_EXPORTER_OTLP_* env vars — so the env-var path documented for CLI clients isn't available as a workaround either.
  • The gateway config reference notes that telemetry.forward_to + listen.public_url enables telemetry and that the CLI stamps user.id/user.email/user.groups from the JWT. Desktop instead receives enduser.id as a resource attribute, so even once ingest is unblocked, Desktop and CLI telemetry arrive under different attribute names and don't aggregate on the same dashboards without a collector-side rename.
  • For contrast, AWS's reference implementation of a third-party bootstrap server (aws-solutions-library-samples/guidance-for-claude-code-with-amazon-bedrock, PRs #669/#670) returns otlpEndpoint together with otlpHeaders carrying per-user identity, and points the endpoint at a collector rather than at the bootstrap server itself. That appears to be the intended shape of a bootstrap response; the first-party gateway does neither.

Why there is no client-side workaround

An MDM otlpHeaders value cannot substitute for the missing one. Per the bootstrap server docs:

When a bootstrap response is available, it is the effective configuration. The MDM profile supplies the trust anchor (bootstrapUrl, optional bootstrapOidc, and the bootstrapEnabled opt-out), and Claude Desktop does not consult MDM for any key the bootstrap server is permitted to set. A bootstrap-settable key that your response omits is treated as unset, not inherited from MDM, so return every key you want applied.

otlpHeaders is marked MDM + Bootstrap in the configuration reference, so it is bootstrap-settable and an MDM value for it is ignored while bootstrapUrl is configured. Combined with the gateway not emitting the key, there is no path by which Desktop can obtain an OTLP credential. The only ways out are both unattractive:

  1. Terminate the OTLP paths at the load balancer ahead of the gateway and accept them unauthenticated, since no credential can be delivered to distinguish legitimate clients.
  2. Abandon bootstrap for Desktop (bootstrapEnabled: false) and configure the whole client through MDM, which restores otlpHeaders but gives up per-group policy, the gateway-derived model list, and per-user config delivery — the reasons to run a bootstrap server at all.

Neither preserves both authenticated telemetry ingest and gateway-delivered policy, which is why this reads as a gap rather than a configuration mistake.

What Should Happen?

One of:

  1. Bootstrap includes a usable credential — e.g. otlpHeaders: {"Authorization": "Bearer <session token>"}. This looks like the natural fix: the response already carries expiresAt and Desktop refetches bootstrap periodically (~30 min in our observation), comfortably inside a 1-hour session.ttl_hours, so the token would rotate on the existing refresh cycle.
  2. The gateway accepts Desktop's exports without a bearer on a distinguishable basis, or supports a second inbound auth mode for the OTLP paths (a static ingest token configurable in gateway.yaml). There is currently no such key — telemetry.forward_to[].headers is outbound-only and access_control is CIDR-based.
  3. Bootstrap omits otlpEndpoint when it can't supply a credential, so Desktop falls back to the MDM-configured endpoint instead of silently failing against the gateway.

At minimum, the gateway should not advertise an endpoint it will then reject, and the mismatch should be visible: today it is silent from the operator's perspective unless someone reads the ingest logs, and silent from the user's perspective entirely.

Error Messages/Logs

The bootstrap response:


{
  "inferenceProvider": "gateway",
  "inferenceGatewayBaseUrl": "https://claude-gateway.example.internal",
  "inferenceGatewayAuthScheme": "sso",
  "otlpEndpoint": "https://claude-gateway.example.internal",
  "otlpProtocol": "http/json",
  "otlpResourceAttributes": { "enduser.id": "user@example.com" },
  "inferenceModels": [ "...5 models..." ],
  "expiresAt": 1785254860
}


Note `otlpEndpoint` is present and `otlpHeaders` is absent.

Every subsequent Desktop flush is refused:


{"ts":"2026-07-28T14:13:09.918Z","evt":"auth.denied","request_id":"...","reason":"missing_token","path":"/v1/logs","client_ip":"..."}
{"ts":"2026-07-28T14:35:22.104Z","evt":"auth.denied","request_id":"...","reason":"missing_token","path":"/v1/metrics","client_ip":"..."}


Meanwhile `desktop_bootstrap.serve` events for the same user return 200 throughout, so this isn't a policy-matching or opt-in problem — bootstrap is working, it just hands over an unusable telemetry configuration.

Steps to Reproduce

  1. Configure a gateway with both listen.public_url and telemetry.forward_to (this is the documented pair that enables telemetry), plus a managed policy carrying a desktop block so GET /user/bootstrap serves instead of 404ing:

```yaml
listen:
public_url: https://claude-gateway.example.internal
telemetry:
forward_to:

metrics: true
logs: true
managed:
policies:

  • match: {}

desktop: {}
```

  1. Point Claude Desktop at it with bootstrapUrl + bootstrapEnabled via managed configuration and sign in. Bootstrap succeeds — the user gets the model list and gateway inference works.
  1. Watch the gateway logs as Desktop flushes telemetry.

Claude Model

Not sure / Multiple models

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.220

Platform

AWS Bedrock

Operating System

Windows

Terminal/Shell

Other

Additional Information

_No response_

View original on GitHub ↗

4 Comments

bluedoors · 26 days ago

Independent reproduction on a different platform and gateway version — confirming this is not environment-specific.

Environment

  • Gateway server: claude 2.1.218, self-hosted ECS Fargate behind an internal ALB (imported public ACM cert)
  • Claude Desktop 1.24012.11 on macOS (Darwin 25.6.0), configured via the in-app "Bootstrap config URL" field (non-MDM machine)
  • Auth: OIDC (Auth0), session.ttl_hours: 12
  • Upstream: Amazon Bedrock (global.anthropic.* inference profiles)
  • Telemetry destination: ADOT collector sidecar in the same task, forward_to: http://localhost:4318, metrics only

So: Windows/2.1.220/MDM/1h TTL in the original report, macOS/2.1.218/in-app-field/12h TTL here. Same failure.

Confirmation of the root cause

otlpHeaders appears zero times in the 2.1.218 gateway binary. The bootstrap response builder emits only:

...telemetryEnabled && {
  otlpEndpoint: publicUrl,
  otlpProtocol: "http/json",
  otlpResourceAttributes: { "enduser.id": claims.email ?? claims.sub }
}

Desktop's own config schema does define otlpHeaders (alongside otlpEndpoint, otlpProtocol, otlpResourceAttributes, otlpContentCapture, otlpTracesEnabled, otlpDesktopLogLevel), so the field exists on the client — the gateway simply never populates it.

Direct reproduction against the running gateway:

POST /v1/logs  (no auth)       -> 401 {"type":"error","error":{"type":"authentication_error","message":"invalid token"}}
POST /v1/logs  (bogus bearer)  -> 401  (identical — confirms it's the credential, not the path)

Observed impact over 48h

| Path | Result | Count |
|---|---|---|
| /v1/logs | auth.denied / missing_token | 108 |
| /v1/metrics | auth.denied / missing_token | 40 |
| /v1/logs, /v1/metrics, /v1/traces | authenticated successfully | 0 |

Zero successful telemetry posts, ever. Meanwhile 57 /v1/messages calls in the same window returned 200, and the ADOT sidecar logged Everything is ready. Begin running and processing data. and then received nothing for the following ~21 hours while the gateway was actively serving inference. The collector is healthy; nothing reaches it.

One additional detail worth adding: Desktop is authenticated at the time it fails.

Correlating audit events by hour, desktop_bootstrap.serve (bearer-authenticated, 200) and auth.denied on the OTLP paths occur in the same hours:

2026-08-05 01:00  auth.denied              117
2026-08-05 01:00  desktop_bootstrap.serve    2
2026-08-05 00:00  auth.denied               13
2026-08-05 00:00  desktop_bootstrap.serve    3

Desktop is therefore holding a valid session bearer — it successfully authenticates to /user/bootstrap minutes before and after posting telemetry unauthenticated. This suggests the fix need not involve minting a separate credential or plumbing a static header: Desktop already has the token it needs and simply isn't attaching it to OTLP exports. Having the client reuse its session bearer for the OTLP paths (when otlpEndpoint is same-origin with the gateway) would resolve it without touching the bootstrap contract, and would sidestep the static-vs-1h-TTL problem noted in the original report.

Either fix works from an operator's perspective; this one just looks cheaper and avoids putting a rotating credential in a config payload.

Second-order effect, confirming the original note

The enduser.id vs user.id/user.email divergence matters more than it first appears: CloudWatch's Coding Agent Insights dashboards segment on user.email, so even after ingest is unblocked, Desktop telemetry won't populate the per-developer views that CLI telemetry does — without a collector-side attribute rename. For anyone deploying the gateway to non-engineering users (Cowork/Desktop rather than CLI), the practical outcome today is no client telemetry at all, and the fallback is server-side data only: the gateway's inference audit events for adoption, and /v1/organizations/spend_limits/effective for per-user cost.

Happy to supply the full audit extract or test a fix against this deployment.

michael-ujifusa-psc · 20 days ago

My organization is running into this issue as well ^

knziiy · 20 days ago

Reproduced on gateway server 2.1.226 (this issue was filed against 2.1.220), so it appears to still be unfixed.

  • Gateway: self-hosted on ECS Fargate (Express Mode), Amazon Bedrock upstream, Amazon Cognito as the OIDC IdP
  • Region: ap-northeast-1
  • Claude Desktop on macOS

Same symptoms: GET /user/bootstrap succeeds (desktop_bootstrap.serve) and inference through /v1/messages works fine, but
every Desktop telemetry flush is rejected:

{"evt":"auth.denied","reason":"missing_token","path":"/v1/metrics","client_ip":"..."}
{"evt":"auth.denied","reason":"missing_token","path":"/v1/logs","client_ip":"..."}

Claude Code CLI telemetry through the exact same gateway and the same telemetry.forward_to config works correctly — metrics
arrive at our collector with user.id / user.email / user.groups stamped from the session JWT. Desktop is the only affected
client, which matches your analysis.

One additional note for whoever picks this up: even once ingest is unblocked, Desktop's enduser.id resource attribute won't
aggregate with the CLI's user.email / user.groups on the same dashboards. For CloudWatch Coding Agent Insights specifically,
the built-in dashboards depend on that attribute shape, so a collector-side rename would be required — which means fixing the
missing credential alone may not be sufficient to get Desktop and CLI usage into one view.

nparekh30 · 19 days ago

Our organization is running into this issue as well.

Showing cached comments. Read the full discussion on GitHub ↗