In this article
August 11, 2026
August 11, 2026

How to tell an AI agent that its user needs to authorize

Your agent calls a third-party API for a user who never connected it. What to send back, why 401 and 403 are both already taken, and what the x402 standard means for the answer.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Most of the time, choosing an HTTP status code is a five second decision with a decade of convention behind it. Not found is a 404. Bad input is a 400. Not logged in is a 401. You do not deliberate.

Then you build a transparent proxy for AI agents, and the decision gets strange. The Pipes token proxy forwards an agent's request to a third party API and streams the provider's response back verbatim: the provider's status, the provider's headers, the provider's body. That pass through behavior is the whole point, and it creates a problem nobody warns you about. The proxy needs to report conditions of its own, and every obvious code for doing so already means something else, because it belongs to the response it is faithfully relaying.

So when a user has not connected the provider yet, what do you return?

The short version, if the shape is all you need: respond with 402, a machine readable code, and an authorization_url the user can complete, then retry the original request once they have. The rest of this post is why the obvious alternatives do not work, which is the part that matters if you are designing something similar.

A transparent proxy has fewer codes than it looks

Start by noticing what a proxied response actually is. Three parties can produce one, and only one of them leaves a trace you can check:

The response came from For example How you can tell
The proxy, about your request A missing header, a bad API key, a URL that is not on the allowlist No X-Token-Proxy-Upstream-Status header
The proxy, about your user's connection The user never authorized, the grant was revoked, the token cannot be refreshed No upstream status header, and an authorization_url in the body
The provider Your GitHub call really did 404, or hit a rate limit, or lacked a scope X-Token-Proxy-Upstream-Status is present

The first row is easy, because those failures happen before anything is forwarded, and a caller debugging them is looking at their own code. The last row is easy too, because you pass it through untouched and it is not yours to interpret.

The middle row is where it gets interesting. It is a failure the caller can fix, but not by changing the request. It gets fixed by a human going and clicking something. And it needs a status code that does not collide with either of the other two rows, which have already claimed most of the plausible candidates.

Why not 401

The instinct is 401 Unauthorized, and it is wrong twice over.

It collides with the proxy's own authentication. The token proxy authenticates you with a WorkOS API key, and a missing or invalid key is genuinely a 401. If "the user has not connected GitHub" were also a 401, then every client would face the same ambiguity on every failure: is my key bad, or does my user need to go authorize something? Those have nothing to do with each other, and they are handled by completely different code paths. One is an ops problem, the other is a user flow.

It also collides with the provider. Plenty of providers return 401 for an expired or revoked token, and the proxy passes those through by design. A caller seeing 401 would have to guess whether it came from WorkOS or from the provider, and guessing based on the body shape is the kind of thing that works until a provider changes its error format.

Why not 403

403 Forbidden is the next instinct and fails the same way, just later.

Providers return 403 constantly, for scope problems, plan limits, org policy, secondary rate limits. GitHub in particular uses 403 for things many APIs would call 429. All of that flows through the proxy verbatim. Minting your own 403 means overloading a code that upstream is already using heavily, and doing it for a condition that is not really "forbidden" at all. Nothing is being refused. The user simply has not been asked yet.

There is a subtler objection too. 401 and 403 both describe a state that the caller may not be able to change. The condition here is explicitly recoverable, and the response carries the recovery instruction. A code that reads as a dead end is the wrong shape for a signal that means "go do this one thing and try again."

Laid out together, the shortlist runs out fast:

Candidate Collides with Verdict
401 The proxy's own auth failure on a bad WorkOS API key, and providers that return 401 for an expired token Ambiguous in two directions at once
403 Providers that lean on 403 for scopes, plan limits, and rate limits, all passed through verbatim Overloaded upstream, and nothing is actually forbidden
404 The proxy's own unknown-provider 404, and the provider's 404s Gives the caller nothing to act on
402 Nothing in practice today, though x402 is now claiming it for payments Unused, unambiguous, and recoverable

The one code nobody was using

Which leaves a short list, and one obvious member of it. RFC 9110, section 15.5.3 defines 402 Payment Required in full as follows: The 402 (Payment Required) status code is reserved for future use.

That is the entire specification. The code was set aside decades ago for digital cash schemes that never standardized, and it has sat unclaimed ever since, which is exactly what makes it useful here. No provider in the supported list returns 402 in ordinary operation, so passing one through is not a realistic collision. It is not already carrying meaning in anyone's client library. When a caller sees a 402 from the token proxy, there is precisely one thing it can be.

So a user with no usable connection for the provider gets this:

  
{
  "code": "token_proxy_authorization_required",
  "connection": "github",
  "message": "User has not authorized provider \"github\"",
  "authorization_url": "https://github.com/login/oauth/authorize?client_id=..."
}
  

The status code tells a client which branch to take. The code field names the condition precisely enough to switch on. And authorization_url makes the response actionable rather than merely descriptive, which matters more for agents than for people: an agent that receives a 402 does not need to know anything about OAuth to make progress, it just needs to surface a link and retry the original call afterward.

Four steps in a row. The agent calls the proxy on behalf of a user, a 402 comes back carrying an authorization_url, the user authorizes while your app surfaces the link, and the agent retries the same request and gets a 200. A dashed line loops from the last step back to the first.
The agent never learns anything about OAuth. It surfaces a link and repeats itself.

One caveat we put in the docs rather than hiding: authorization_url is null when WorkOS cannot construct one, which usually means the provider's Pipes configuration is incomplete. Clients should fall back to their own connection flow instead of assuming the field is present. A recoverable error that sometimes omits the recovery step is worse than useless if nobody told you.

The other half of the problem: Whose 404 is this?

Picking a code for your own conditions solves half of it. The other half runs the opposite direction. If the proxy can generate a 404 for an unknown provider, and GitHub can generate a 404 for a missing repo, then a caller receiving 404 still cannot tell where it came from.

You cannot fix this with status codes, because you have run out. It needs a channel outside the status line, so every proxied response carries a header:

  
X-Token-Proxy-Upstream-Status: 404
  

Its presence means the request reached the provider. Its absence means it did not. That single header converts an ambiguous class of failures into a decidable one, and it costs nothing. If you take one thing from this post and you are building anything that relays somebody else's responses, take this: decide early which parts of the response are yours, and give yourself an out of band way to say so. Status codes are a namespace you are sharing, whether you planned to or not.

The wrinkle we did not fully solve

Honesty about the seam. Pipes connections are either scoped to an organization or not, and the lookup requires an exact match. Send X-Token-Proxy-Organization when the connection is user scoped, or omit it when the connection is org scoped, and you get the same 402 token_proxy_authorization_required as a user who never connected at all.

That is defensible, because from the proxy's point of view there is no usable connection matching what you asked for. It is also a debugging trap: the response tells you to send the user through OAuth again, which will not help, because the user is already connected and your header is simply wrong. They will authorize, you will retry, and you will get the same 402.

A distinct code for scope mismatch would be more useful to debug against, and there is a reasonable argument against adding one: telling a caller that a connection exists under some other scope reveals a little about a user's connected accounts that the caller did not otherwise have. Either way, the practical advice is the same, and it is in the docs. If you keep getting a 402 for a user you know is connected, check the organization header before you blame the grant.

The awkward part: 402 is being claimed

A code that is reserved for future use is only free until the future arrives.

While 402 sat unused, it also sat available, and the agent payments ecosystem has now taken it. x402, contributed by Coinbase to the Linux Foundation, uses 402 for its original purpose: a server answers with 402 and payment details, the client settles in stablecoin and retries with a receipt header. The x402 Foundation launched in April 2026 and was declared operational in July with 40 members. That is no longer a proposal. It is a standard with an ecosystem.

So the honest position is that the token proxy uses a code that another specification is in the middle of claiming, and that the reasoning above has a shelf life. Three things make the overlap survivable rather than broken.

The pattern is the same in both cases, which is the useful coincidence. Both say: this request cannot proceed until you satisfy a precondition, here is what it is, retry when you have. A client that already knows how to handle a 402 by reading the body and retrying is structurally ready for either one.

The bodies are unambiguous. x402 responses carry payment requirements and its own headers. A token proxy 402 carries code and authorization_url. Any client that switches on the body rather than the bare status code will never confuse them.

And the token proxy is not a public endpoint that an arbitrary agent stumbles onto. You configure it deliberately, with your own API key. The ambiguity that would actually bite is a generic agent runtime that speaks x402 natively and treats every 402 as a request for money. If you are building that runtime, branch on the response body, not the status line.

None of that makes the collision imaginary. If we were choosing today, with x402 formalized rather than emerging, the calculus would be closer, and the answer might be a 409 or a 424 with a very explicit code. That is worth saying out loud, because the interesting part of a design decision is usually its expiry date.

The general shape

Behind the specifics there is a pattern worth naming, and it shows up any time you put something in the middle of somebody else's protocol.

A transparent intermediary has to distinguish three voices in one channel: its own complaints about you, the upstream's complaints about you, and its own complaints about the relationship between you and the upstream. HTTP gives you one status line to carry all three, and the upstream has first claim on most of it. So you end up doing what the token proxy does. You find the quietest corner of the namespace for your own conditions, you add an out of band header so callers can always tell who is speaking, and you keep your hands off everything that is not yours.

Which is a long way of saying that the boring looking part of an API, the error table, is usually where the design actually happened.