Get started

Engineering NotesSecurity and audit

Connector OAuth callbacks finish at once only in the starting browser

OwlAI's per-user connectors (external services such as MCP servers that a company connects to OwlAI) attach through OAuth. When a user clicks Connect, OwlAI builds an authorization URL carrying a state, and the user signs in to a service such as GitHub and grants access. The service sends the browser back to OwlAI's callback with an authorization code and the state. That callback is a top-level navigation arriving from another site, so it carries no OwlAI login. The server therefore saves the connection under the person it recorded alongside the state when authorization started.

This opens one attack. A colleague starts a GitHub connection with their own OwlAI account, copies the authorization URL without opening it, and sends it to the victim. If the victim is signed in to GitHub and has approved this app before, GitHub skips the authorization page and redirects straight to the callback. The victim only opened a link, yet the victim's GitHub token is now saved as the colleague's connector connection, and every GitHub tool the colleague calls in chat from then on runs with the victim's permissions. It reads private repositories only the victim can see, and if the connector has write tools, it comments on issues in the victim's name. The state is genuine and was used once, and the PKCE exchange succeeds.

This post covers how we tie the callback to the browser that started authorization, and why a callback that comes back in a different browser stops at a confirmation page instead of being rejected. The confirmation page shows the OwlAI account that will receive the connection, and the connection finishes only with a one-time token carried on that page alone. The binding cookie works the same way as in the Supabase import (“Supabase connection addresses are filled by OAuth approval, not copied by hand”), so this post covers what differs for connectors.

The callback saves the connection under the person recorded with the state

The CSRF most OAuth documents describe runs the other way. In the attack covered by RFC 6749 section 10.12 and RFC 9700 section 4.7, the attacker injects their own authorization code into the victim's callback, and the victim's client session gets attached to the attacker's external account. Files the victim saves then go to the attacker's storage. That direction does not work against OwlAI connectors. The callback never looks at who is signed in to the browser; it saves the connection under the person recorded with the state when authorization started. If an attacker gets a victim to open the attacker's own callback link, the attacker's external account simply lands on the attacker's OwlAI account.

The exposed direction is the one in the first paragraph: the attacker starts authorization, the victim approves, and the connection stays with the attacker. It has the same shape as the OAuth 1.0 session fixation attack (2009). The victim does not even need to be an OwlAI user, only signed in to that service.

Neither state nor PKCE stops this direction. The state cannot be guessed, but the attacker who started authorization knows it. The PKCE code verifier stays on the OwlAI server, which sends it itself during the exchange, so the exchange succeeds no matter whose browser opened the callback. RFC 9700 also requires binding state to the user agent that started the request. What remains to check is whether the browser that opened the callback is the one that started authorization.

The browser that started authorization holds a one-time cookie

When a user clicks Connect, the server builds the authorization URL and leaves three things behind.

  • A row holding the state and the PKCE code verifier. It expires after 10 minutes and is deleted once the callback reads it.
  • A one-time random cookie set in that browser. It is HttpOnly and SameSite=Lax, so it also rides along on the top-level navigation back from the service.
  • Only a hash of the cookie value, stored in the row.

If the cookie on the callback matches the hash in the row, the server exchanges the authorization code right away and saves the connection. This covers the usual case of a user clicking Connect in a browser. The cookie is deleted when the result page is returned.

The callback address is fixed too. The redirect URI in the authorization URL is always the callback built from this installation's public address, and the request body cannot change it. If it could, the authorization code would go to an address chosen by whoever made the request. It is also the value that must match the address registered in the service's OAuth app.

The Supabase import (“Supabase connection addresses are filled by OAuth approval, not copied by hand”) ends a callback without the cookie with an error. Connectors cannot do that. In the desktop and mobile apps, the user clicks Connect in the app, but the sign-in page opens in the system browser and the callback returns there too. The system browser does not have the cookie, so even a connection the user legitimately started arrives without it. Rejecting those callbacks would leave app users unable to connect any OAuth connector.

So a callback with a missing or different cookie does not exchange the authorization code; it returns a confirmation page instead. Nothing is saved at this step. The page reads:

Finish this connection? This connects your GitHub account to the OwlAI account a***@example.com. If you did not start this connection or that OwlAI account is not yours, close this window.

The email shows only its first character and the domain. The page opens without a login, so it cannot show the full address of the person who started the connection to anyone who opens the link. A user who connected from the app checks their own account and clicks Connect. A victim who opened a link someone else sent sees an account that is not theirs and a sentence telling them to close the window if they did not start this.

Callback binding: the starting browser connects at once, others go through a confirmation page
Figure 1. Callback binding: the starting browser connects at once, others go through a confirmation page

"Connect" sends a one-time token carried only on that page

Connect on the confirmation page POSTs three fields to the same callback address: the authorization code, the state, and a confirmation token. The server generates the confirmation token as a fresh one-time random value when it renders the page and stores only its hash in the state row. Reopening the page issues a new token, and the previous one stops working.

The token does the same job as a CSRF token hidden in a form. The attacker knows the state and the callback address, so they can make their own site submit the same POST automatically. But the confirmation token exists only inside the OwlAI page rendered in the victim's browser, and the same-origin policy keeps sites on other origins from reading that page. A POST with a missing or wrong token does not finish the connection. Confirmation is possible only within the 10 minutes the state is alive.

The confirmation page and the callback result page carry three response headers.

Header What it blocks
X-Frame-Options: DENY Clickjacking: another site loading this page in a transparent frame to get Connect clicked
Referrer-Policy: no-referrer The authorization code in the URL leaking through the Referer of the next request
Cache-Control: no-store The page containing the authorization code staying in the browser cache

Once the connection is saved, the audit log records the OwlAI user who started authorization as the actor (“Auditing an AI agent: log every query it runs, not the conversation”). The callback request itself has no signed-in person, so the person recorded with the state is both the owner of the connection and the actor.

Design choices

We chose a confirmation page over rejecting callbacks without the cookie. Rejection is a simpler rule. But then the desktop and mobile apps, which sign in through the system browser, could not connect OAuth connectors at all. The cost is that app users click once more on the confirmation page each time they connect.

We put the binding in a separate cookie rather than in the state alone. Common web frameworks store the state in the login session and check at the callback that the session matches. OwlAI's callback request carries no login, so we set a cookie used for a single connection. The cookie holds one value per authorization, so if you start two connections back to back in the same browser, the earlier one finishes through the confirmation page.

The cookie value and the confirmation token are stored only as hashes. Someone who can read the database row still cannot finish the callback with them.

The same conditions apply to any design where a callback that arrives without a login saves the connection under the person recorded with the state. Block the direction where the attacker starts and the victim approves first, by checking that the browser that opened the callback is the one that started authorization. If there is a legitimate path where the browser changes, as with an app, add a confirmation step that shows the receiving account instead of rejecting, and protect that step with a one-time token as well.