headroomlabs-ai/headroom · error · OAuth2Error
token endpoint response had no access_token
Error message
token endpoint response had no access_token
What it means
The OAuth2 client credentials provider in headroom-oauth2 successfully reached the identity provider's token endpoint and got an HTTP 200 with a JSON body, but the parsed payload contained no usable 'access_token' field (missing, null, or empty string). The provider only treats HTTP errors, connection failures, and malformed JSON as separate failures, so this error specifically means the IdP answered 'OK' but did not hand out a token. This almost always indicates a configuration mismatch (wrong token URL, wrong grant parameters) rather than a network problem.
Source
Thrown at plugins/headroom-oauth2/src/headroom_oauth2/provider.py:141
self.token_url,
data=urllib.parse.urlencode(form).encode(),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
payload = json.load(resp)
except HTTPError as e:
with suppress(Exception):
e.read() # drain; do NOT surface the IdP body (may echo sensitive context)
raise OAuth2Error(f"token endpoint returned HTTP {e.code}") from None
except (URLError, OSError) as e:
raise OAuth2Error(f"token endpoint unreachable: {e}") from None
except json.JSONDecodeError:
raise OAuth2Error("token endpoint returned non-JSON") from None
token = payload.get("access_token")
if not token:
raise OAuth2Error("token endpoint response had no access_token")
raw = payload.get("expires_in")
try:
ttl = int(float(raw)) # tolerate "3600", "3600.0", 3600, or a JSON float
except (TypeError, ValueError):
ttl = 300
ttl = max(1, ttl) # 0/negative would cause a stale token or per-request minting
log.info("oauth2: minted token (ttl=%ss, scopes=%s)", ttl, self.scopes or "-")
return token, ttl
View on GitHub (pinned to 322425c43b)
Solutions
- Manually reproduce the token request with curl (POST to the configured token URL with grant_type=client_credentials, client_id, client_secret) and inspect the JSON body — it will usually show an error field or a login page instead of a token
- Verify the token URL in the provider config is the IdP's actual token endpoint (e.g. https://idp.example.com/oauth2/token, not the issuer base or /authorize)
- Confirm the client is allowed to use the client_credentials grant and that the requested scopes are registered for that client
- Check for an intermediary (API gateway, service mesh sidecar, captive proxy) that returns 200 with its own JSON on failure
Example fix
# before: token_url pointed at the issuer base
provider = OAuth2Provider(
token_url="https://idp.example.com/oauth2/", # 200 JSON, no access_token
...
)
# after: point at the real token endpoint
provider = OAuth2Provider(
token_url="https://idp.example.com/oauth2/token",
...
) Defensive patterns
Strategy: try-catch
Validate before calling
import urllib.request, json, urllib.parse
def token_endpoint_smoke_check(token_url: str, client_id: str, client_secret: str) -> bool:
"""Preflight: confirm the token endpoint mints an access_token before wiring the provider."""
data = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
}).encode()
req = urllib.request.Request(token_url, data=data, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return bool(json.load(resp).get("access_token"))
except Exception:
return False Type guard
from headroom_oauth2.errors import OAuth2Error
def is_oauth2_config_error(exc: BaseException) -> bool:
"""True when the provider rejected the token response shape (config-level, not transient)."""
return isinstance(exc, OAuth2Error) and "no access_token" in str(exc) Try / catch
from headroom_oauth2.errors import OAuth2Error
try:
token, ttl = provider.fetch_token()
except OAuth2Error as e:
if "no access_token" in str(e):
# IdP answered 200 but issued no token: configuration problem, do not retry blindly
raise RuntimeError(f"IdP token endpoint misconfigured: {e}") from None
raise Prevention
- Verify the token endpoint with curl (grant_type=client_credentials) once during setup and after any IdP config change
- Pin the token URL from a single config source of truth so dev/prod endpoints cannot be mixed
- Add a startup health check that mints one throwaway token before marking the service ready
When it happens
Trigger: Calling the token fetch on the provider (any path that ends up requesting a token via client credentials) where: the token_url points at the IdP's base URL or an introspection/userinfo endpoint instead of the token endpoint; the IdP returns 200 with an error payload (some proxies and API gateways do this); the client is not registered for the client_credentials grant so the IdP returns a body without access_token; a required scope or assertion parameter is missing and the IdP responds non-standardly.
Common situations: Auth server URL copy-pasted without the /token path; wrong environment's token endpoint (dev vs prod IdP); IdP or gateway rewrites error responses to 200 JSON; client secret valid for a different client_id; service accounts not enabled for machine-to-machine grants.
Related errors
- Invalid {TRUSTED_DASHBOARD_CLIENT_CIDRS_ENV} entry: {exc}
- {key}={raw!r} is not an integer
- headroom-oauth2 misconfigured: {e}
- token_url is required
- client_id and client_secret are required
AI-assisted analysis of headroomlabs-ai/headroom@322425c43b (2026-08-15).
Data as JSON: /api/errors/ccad7d1b1b4408b6.
Report an issue: GitHub.