Skip to content

openai.error.AuthenticationError: <empty message>

An exception that carries no explanation is still carrying information. The class name tells you a 401 came back, which rules out most of what you might fear: a quota or pacing refusal arrives as 429, an unsupported region as 403, a malformed request as 400, and a network failure as a connection error with no status at all. So a response did arrive, and it said you are not authorized. What the blank message tells you is separate and more useful: whatever sent that 401 did not send OpenAI’s error body.

Data as of 2026-09. Vendor limits and defaults change; check the official docs for current values before acting on any number below.

The class and the message have different sources

This is the mechanism the page turns on, and it is why an empty message is a clue rather than a defect.

The SDK chooses the exception class from the HTTP status line alone. 401 becomes AuthenticationError, 403 becomes PermissionDeniedError, 429 becomes RateLimitError, anything at 500 or above becomes InternalServerError. That mapping runs before anything parses the body, and it cannot fail — a status code is always present on a response.

The message is a different thing entirely: it is lifted out of the response body, from the message field of the error object. If the body is not that shape — an HTML sign-in page, a bare Unauthorized string, a zero-length body, a gateway’s own JSON with different field names — there is nothing to lift, and you get a correctly-classified exception with nothing to say.

So the pairing “right class, no message” is a statement about the responder, not about your credential. OpenAI’s API documents messages for 401, so a 401 from OpenAI normally arrives with prose attached. One that arrives silent is the shape you get from an intermediary.

Before anything else: find out what answered

There is a direct test, and it takes one line of logging.

OpenAI responses carry an x-request-id header. The SDKs surface it on success as _request_id, and on a failure you catch the status error and read request_id in Python or requestID in Node. Non-2xx responses raise a subclass of the SDK’s status-error type, which carries both status_code and the raw response object — so the headers and the untouched body are still in your hands at the moment you catch.

  • No request id, no error body — the leading hypothesis is that your request never reached the API. Something in front of it refused you: a corporate proxy that wants its own credentials, an API gateway, a service mesh, or a reverse proxy sitting in front of an OpenAI-compatible endpoint. A WWW-Authenticate header in the response is close to conclusive, because the API does not need to challenge you that way.
  • A request id is present — then OpenAI itself rejected you, and the blank message is a local artifact. Check whether your own code or framework caught the original exception and re-raised a thinner one; the body you need may have been discarded two frames above where you are reading.
  • A body exists but is HTML — read the first line of it. A login page, a block page or a vendor-branded error page names the product that stopped you.

While you are in there, resist branching on error.type. Only a few type strings are documented by name, none of them for 401, and a response with no message very likely has no type or code either. The exception class is the reliable discriminator here precisely because it is derived from the status code.

When the 401 really is OpenAI’s: two causes survive a correct key

The documented reasons for a 401 are invalid authentication, an incorrect API key, not being a member of an organization, and a source IP that is not on the allowlist. The last two are the ones that cost people afternoons, because they produce the same 401 while the key itself is perfectly valid — which is why regenerating it changes nothing, twice.

  • Wrong or absent credential — observation: the failure follows a deploy, an environment change or a new machine, and it fails from every network.
  • Not a member of the organization — observation: the request names an organization or project the caller does not belong to. This one fails identically from every network too, so the network test cannot separate it from the case above; the org or project identifier your client is sending can.
  • Source IP not allowlisted — observation: the same credential works from one network and fails from another. This is the only one of the four where changing location changes the answer, which makes it cheap to confirm and easy to miss.

Is retrying useful?

No. The refusal is computed from credentials that did not change between attempts.

The SDKs already agree with that verdict by omission: their automatic retry covers connection errors, 408, 409, 429 and 5xx responses. 401 is deliberately not on that list, so nothing was retried before you saw this, and the exception in your hands is the first and only attempt.

If something in your stack is looping on it — a framework-level retry decorator, a job runner, an agent that re-invokes a failed tool — turn it off for this class. Every attempt is a request that gets counted, and failed requests still count against your per-minute limit, so a retry loop on a 401 can manufacture a second, unrelated problem while you debug the first. The one “retry” that is worth performing is a single re-run with the response captured, so you have the headers and the raw body you did not log the first time.

Checking the credential without ever exposing it

Do not paste an API key into a search box, an issue tracker, a chat window, or a page like this one. A key that has been pasted anywhere is a key to rotate, and you do not need its contents to debug this.

You need its properties, and those are safe to print:

import os
key = os.environ.get("OPENAI_API_KEY")
print(key is None, len(key or ""), (key or "") == (key or "").strip())

Three booleans and a length, and no key material. They catch the majority of real causes: the variable that is not set at all (many wrappers then send an empty credential and receive exactly this 401), the value that picked up a trailing newline from a file or a shell heredoc, and the value whose length is obviously wrong because a shell expanded or truncated it.

Then check which credential won. The SDK takes an explicit argument in preference to the environment, frameworks layer their own configuration files on top, and a process started before you edited your environment is still running with the old value. Log the length of the key the client actually holds at the call site — not the one you believe you exported. Masked output like sk-… followed by nothing is enough to tell two keys apart in a log without putting either of them in it.

Fix by scenario

  • An intermediary answered — fix the proxy or gateway credential, or route the API call around it. The API key is not involved and changing it is wasted work.
  • base_url points somewhere unexpected — print the resolved base URL at the call site. A stale environment variable pointing at a local proxy or a compatible-API gateway is one of the commonest ways to get a silent 401.
  • Credential missing or malformed — set it in the environment the process actually reads, then restart the process. Strip whitespace at the point of reading rather than trusting the file.
  • Organization or project mismatch — send the identifier for an org and project the credential belongs to, or remove the override entirely and let the default apply.
  • IP allowlist — add the egress address of the machine that is failing. In a container, on a CI runner, or behind a NAT gateway that is not the address you see on your laptop.
  • The message is empty because your code dropped it — stop re-raising bare; attach status_code, request_id and the first part of the response body to whatever you raise instead.

How to confirm it’s fixed

A single 200 is enough here, because nothing about a 401 is intermittent — but it has to be the right 200. Re-run the same call, from a freshly started process, on the machine and network that were failing, with the same organization and project settings, and confirm the response carries an x-request-id. The request id is the part that matters: it proves you reached the API rather than getting a cheerful answer from a cache or a mock.

If your fix was for the allowlist case, running it successfully from your laptop proves nothing at all, which is the exact way this one gets closed and reopened.

  • invalid_api_key Incorrect API key provided: undefined is the same 401 arriving with its message, and the word undefined in it is a specific diagnosis — read that one if your message is not blank after all.
  • HTTP 400 Bad Request: invalid_redirect_uri is the sign-in flow failing rather than the API call, which is a different fix in a different place despite looking like the same category.
  • openai.error.APIConnectionError is what you get when nothing answered at all — useful for contrast, because the presence of a status code is what makes this page an auth problem and that one a transport problem.
  • The AI coding error triage tool sorts failures by the signals used above: whether a status code exists, whether a request id exists, and whether the body came from the vendor.