Get started

Engineering NotesSecurity and audit

Connector shows Connected but returns 401: refresh by response code

When you attach a connector (an outside service such as an MCP server that a company connects to OwlAI) with OAuth, each user signs in once with their own account, and OwlAI calls tools with the access token it received then. Access tokens expire soon, so OwlAI has to get new ones with the refresh token. If refresh relies only on the stored expiry time, it breaks in two places. With an authorization server that leaves the expiry time (expires_in) out of its token response, the stored expiry is empty and refresh never happens: the screen says Connected while every tool call fails with 401. And if every refresh failure is treated as a reason to sign in again, a single 500 from the authorization server stops all of that connector's tools until the user reconnects by hand.

This post covers the rules that solve both. When to refresh is decided by the expiry time together with the 401 the MCP server actually returns. When a refresh fails, the authorization server's response code decides whether to ask the user to reconnect. How the tokens are encrypted at rest is covered in “Database backups carry stored passwords, so the key lives elsewhere”.

Expiry-only refresh stops when there is no expiry

The usual approach goes like this. When a token arrives, compute its expiry from expires_in and store it. Before calling a tool, if that time has passed, exchange the refresh token for a new token. If the refresh fails, put a reauthentication-required flag on the connection and have the user sign in again.

In the OAuth 2.0 standard, expires_in is only recommended, and some authorization servers really do leave it out. Connected to such a server, the stored expiry is empty and an expiry-driven refresh never runs. The user's connector screen reads the same stored values to show its status. There are four statuses, Not connected, Connected, Expired, and Error, and an empty expiry with no reauthentication flag means Connected. After the token has died on the server side, the screen still says Connected and shows no Reconnect button. The user sees tool errors with no way to fix them.

Treating every failure the same has its own problem. If the authorization server returns a 500 for a moment, or a corporate proxy returns an error page, signing in again gives the same result. Yet once the reauthentication flag is set, it stays until the user reconnects, and meanwhile every call to that connector's tools fails without even reaching the authorization server. The authorization server may recover in seconds while the user goes hours without the tools.

Refresh early near expiry, and keep the current token if that fails

Token refresh only happens on OAuth connectors, and every OAuth connector uses per-user authentication. There is no flow for an administrator to sign in with a shared account and obtain a token, so the registration screen leaves only per-user authentication once OAuth is chosen, and the server API rejects requests that combine shared authentication with OAuth. Every rule below therefore runs on one unit: this user's connection to this connector.

On every tool call, OwlAI reads that user's connection and checks the expiry time.

Expiry time Behavior
More than 3 minutes away Call with the stored token
Within 3 minutes Refresh early. If the refresh fails, call with the still-valid token
Already passed Refresh, then call with the new token. If that fails, follow the refresh-failure rules below
Empty Call with the stored token, and refresh if a 401 comes back

The early refresh exists to keep a token to fall back on when a refresh fails. If refresh waits for the first call after expiry, a single failed refresh leaves no usable token, and the call fails with it. Refreshing within three minutes of expiry means that if the refresh fails, the current token still works for the remaining minutes, and the next call tries the early refresh again. In the meantime the user notices nothing.

On a 401, refresh once regardless of expiry and call again

When the expiry is empty, the MCP server's 401 is the only sign that the token is done. So when a tool call on a per-user OAuth connector gets a 401, OwlAI refreshes once without looking at the expiry time. If the refresh succeeds, it calls the same tool once more with the new token. The user sees a normal result that arrived a little later.

If the second call also gets a 401, OwlAI stops refreshing. The refresh succeeded, yet the new token does not work on this server, which usually means the service withdrew the permissions granted to the app or the required permissions changed. OwlAI then puts the reauthentication-required flag on the connection and tells the user "The permissions required by {connector name} have changed. Please reconnect." Because the refresh and the second call each happen once, a single call cannot loop forever against a server that keeps answering 401.

A per-user connector where users enter their own API key has no refresh token. When such a connector gets a 401, OwlAI skips the refresh and sets the reauthentication-required flag right away. The user enters a new key with Enter token on the connector screen.

How one tool call picks its token, and the two outcomes of a failed refresh
Figure 1. How one tool call picks its token, and the two outcomes of a failed refresh

Refresh failures are split by the authorization server's response code

When the request that exchanges a refresh token for a new token fails, OwlAI sorts the failure into one of two kinds. The test is whether signing in again would change the outcome.

Failure Classification Handling
4xx from the authorization server (invalid refresh token, invalid client), or no stored refresh token A failure that retrying will not change Set the reauthentication-required flag on the connection. The status changes to Error or Expired and Reconnect appears
5xx from the authorization server, network errors and timeouts, non-JSON responses Transient failure Fail only this tool call. No flag is set, so the next call refreshes again

Treating a 4xx as a failure that retrying will not change follows the OAuth standard. The standard has the token endpoint answer with a 4xx and an error code such as invalid_grant or invalid_client when it refuses the refresh token or the client. This covers a user disconnecting the app on the service side or a refresh token reaching the end of its lifetime, and only signing in again fixes it.

A 5xx or a network error is a problem with the authorization server or the path to it, so signing in again produces the same error. Non-JSON responses go on the same side. They come from a corporate proxy or gateway returning an HTML error page in place of the authorization server, which is not the authorization server's judgment and so is no evidence that the refresh token is invalid. With no flag set on these failures, the first call after the authorization server recovers refreshes successfully on its own.

A 4xx caused by another request refreshing first is not a reason to reauthenticate

There is one case where a 4xx does not mean the user must sign in again. With an authorization server that issues a new refresh token every time one is used (rotation), two refreshes can go out at once with the same refresh token. This happens when the connector screen refreshes an expired token as the user opens it, at the same moment a tool call in chat refreshes it too. The request that arrives first gets the new token; the later one sent a refresh token that was already used, and gets a 4xx. If the later one set the reauthentication-required flag, it would cut off a connection that had just been refreshed successfully.

So on a 4xx, before setting the flag, OwlAI rereads the stored connection a few times at short intervals. If the stored refresh token differs from the one it sent and the new access token is still valid, another request refreshed successfully, and OwlAI calls the tool with that token.

Saving a refresh result guards against the same race. A refresh does not lock the connection row while it waits on the authorization server. When saving, OwlAI locks the row and overwrites it only after checking that the stored token matches the one read when the refresh started, and that the connector's server address and resource are unchanged. If an administrator changed the server address in the meantime, the connection information obtained for the old address has already been deleted and each user must reconnect. A refresh that finished late would otherwise write a token for the old address back in, and that token would be sent to the new address. When the check fails, the received token is discarded, and if another refresh saved a token first, OwlAI uses that one.

In chat, the same call runs once more after reconnecting

When reauthentication is needed, a Connect to {connector name} window opens in chat, with the reason. If the authorization server refused the refresh token, it says "Failed to refresh the {connector name} token"; if the refreshed token did not work on the server, it says "The permissions required by {connector name} have changed." Once the user finishes signing in in the new tab, OwlAI calls the paused tool call once more with the new token and continues the turn (one question taken through to an answer). The user does not have to type the question again.

If the user clicks Later, that connector's tools are unavailable until the turn ends, and the other tools keep working. The window opens at most once per tool call, and for a server that still demands authentication after the user reconnects, it does not open again in that turn.

Transient failures open no window. While the authorization server is returning 500, signing in again would get the same 500. That call ends in failure and the model receives a tool error as the result. The next call to the same connector tries the refresh again from the start.

Design choices

When the expiry is empty, we could fill in a guessed default lifetime. Setting it to one hour would match most authorization servers and keep the early-refresh rule working. But the authorization server never stated that value, and the screen's Expires in and expiring soon would present the guess as fact. On a server with a shorter lifetime, a 401 would arrive before the guessed expiry, so separate 401 handling would be needed anyway. So stored values hold only what the authorization server sent, and we chose the actual 401 as the refresh signal. That costs two things. The first call after expiry is slower by one round of 401, refresh, and second call. And until that first call, the screen shows Connected.

Flagging every refresh failure as reauthentication-required would be simpler, and the user would see a single, unambiguous state. In exchange, a brief authorization server outage would turn into a manual reconnect for every user. Splitting by response code leans on the OAuth standard's error responses, so the same rule holds however differently authorization servers are implemented. The cost is that while transient failures continue, every tool call sends one refresh request to the authorization server. If the authorization server stays down for a long time, users see only tool errors with no prompt to reconnect.

After a 401, the refresh and the second call each happen once. Refreshing again on a second 401 could rescue one more rare race, but against a server that has withdrawn permissions, a single call would keep repeating the refresh. We treat a second 401 as a case that only reconnecting fixes, and tell the user right away.

If your agent connects to several outside services with per-user OAuth, these rules carry over as they are. Decide when to refresh from both the stored expiry and the actual 401, ask users to sign in again only on the authorization server's 4xx, and even on a 4xx, reread the stored values to check whether another request refreshed first.