---
title: "An agent's connect link must check who opens it | FoxAuth"
description: "An agent hands a person a link to connect an account. If the link works for whoever opens it, a forwarded link connects the wrong account to the wrong agent."
canonical: "https://foxauth.dev/blog/an-agents-connect-link-must-check-who-opens-it/"
lastmod: "2026-11-03T11:29:19+03:00"
---
Blog
An agent's connect link must check who opens it
An agent hands a person a link to connect an account. If the link works for whoever opens it, a forwarded link connects the wrong account to the wrong agent.
By FoxAuth · 2026-11-03

An AI agent called Repo Helper needs to read a person’s GitHub repositories. It has no access yet, so
it writes in the chat: “Open this link to connect your GitHub account.” The person clicks. They sign in
to GitHub and approve. Then they go back to the chat. Repo Helper tries again, and this time it gets a
token for GitHub.

Now put a second person into the same story. Mallory asks her own copy of Repo Helper for the same
thing and gets the same kind of link. She does not open it. She sends it to her colleague Alice with a
note: “Can you connect this for the team?” Alice opens the link. She is already signed in to GitHub,
so approving takes two clicks. But the link was made for Mallory. So Alice’s GitHub account is now
connected to Mallory’s agent, and Mallory’s agent can read Alice’s private repositories.

Nothing in that story is broken in the usual sense. Every redirect went where it should. Every token
was genuine. GitHub saw a real sign-in by the real Alice. One step is missing: a question nobody asked
when the link was opened. Is the person in this browser the person this link was made for?

This post is about that question. I build an authorization server, FoxAuth. Since version 1.4.0 it
hands agents links like this one. Before building it, I read how five other products handle the same
link. Only one of them documents a check of the person who opens it. MCP (Model Context Protocol) is
the protocol many agents use to reach tools. Its specification requires this check, and it describes
this exact attack.

A connect link joins two identities, and whoever opens it supplies the second

A connect link has one job: to join two identities. The first is the person in your own system, the
user the agent works for. The second is that person’s account at another service, for example GitHub.

The link is created when the agent asks for it. At that moment the server knows the first identity,
because the agent’s request names the person. The server cannot know the second identity yet. The
second identity arrives later, from the browser that opens the link: whoever signs in to GitHub in
that browser supplies it.

So if the link does not check who opens it, the person who opens it decides what gets joined. That
can go wrong in two directions. The opening story is the right half of the picture below, with you in
Alice’s place.

On the left, your link reaches someone else. Perhaps you pasted it into a shared channel, forwarded
it by mistake, or left it in a chat log. Whoever opens it connects their own GitHub account to your
agent. From then on your agent writes to their repositories. It may copy your private data there,
believing the account is yours.

On the right, someone else’s link reaches you. You connect your own account to their agent. This is
the direction phishing uses, because the attacker decides when the link is sent and what the message
around it says.

Both directions come from the same mistake. The link trusted that the right person would open it.

A short lifetime and single use do not stop a forwarded link

The usual protection for such links is to make them expire quickly, to let them be used only once, or
both. Here is what each product’s documentation says, as of November 2026:

Product
How long the link lives
Documented check of who opens it

Composio
10 minutes to finish connecting
None; the developer is told the callback alone is not proof of ownership

Nango
30 minutes; “can be used as a magic link”
None

Pipedream Connect
4 hours; the docs suggest email or SMS
None

Auth0 Token Vault
A 5-minute session
The agent never gets the link; at the end, same session and same device

Arcade
Not documented
Yes: before the account is saved, the developer confirms the user

The table records only what the documentation says. A product may check more than it documents, but a
developer building on it cannot count on that.

Neither protection stops the attack in the opening story. Mallory chooses when to send the link, so
she can ask for a fresh one a minute before she sends it. A link that works only once does not help
either. Alice is the first and only person to use it. The link works exactly as designed. It is simply
used by the wrong person.

Auth0 avoids the problem in another way. In Auth0’s design, the agent never sees a link at all. The
application the person is using asks for the link with the person’s own access token, and sends the
person to it. When the person comes back, the application completes the connection. At that point
Auth0 checks that the request belongs to the same signed-in session and comes from the same device.

That works when the agent lives inside one web application with one browser session. It does not work
for an agent in a chat client, in a terminal, or in an MCP host, the program (such as a desktop chat
app) that connects an agent to its tools. There, the only way to reach the person is to show them an
address.

Check the person at the moment the link is opened

The MCP specification
says so directly. An MCP server can ask the program in front of the person to show them an address.
The specification calls this a URL elicitation. Its security section tells the opening story almost
step by step: the attacker gets a link, tricks another user into opening it, and the victim’s tokens
end up tied to the attacker’s identity. Its rule follows: the server “MUST verify the identity of the
user who opens the URL before accepting information”. Its example does not send the person straight
to the other service. It sends them to a page on the MCP server itself. That page compares the user
signed in there with the user the request came from.

FoxAuth works the same way. Repo Helper holds an access token that FoxAuth issued for one person. It
presents that token to ask for the same person’s GitHub token. If the person has not connected GitHub,
or has not allowed this agent to use it, FoxAuth refuses. The refusal carries a link made for that
person.

The link points at a page in the person’s project. A project in FoxAuth is a set of people and the
applications they sign in to, with its own sign-in page. When the link is opened, the server goes down
a short list of checks before it shows anything:

Is the code at the end of the link one this server issued, and is the link still open? A code that
is unknown, expired, declined by the person, or replaced by a newer link gets the same 404 page. So
someone guessing codes learns nothing about which links exist.

Is anyone signed in to the project? If not, the person signs in first and then returns to the link.

Is the signed-in person the one the link was made for? If not, the page says the link was made for
another account, and nothing changes. The server also emits an event about the refusal, which an
operator can subscribe to.

Did that person sign in within the last fifteen minutes? If not, they sign in again. A browser left
signed in on a shared computer is not enough to connect an account.

Only now does the page offer what the link was made for. There are three possibilities: connect
GitHub and let this agent use it, let this agent use a GitHub account that is already connected, or
reconnect an account whose GitHub access stopped working.

Replay the opening story with these checks. Alice opens Mallory’s link. Alice is signed in, but she is
not Mallory, so check 3 stops her and nothing is connected. Now suppose Mallory gets hold of Alice’s
link instead. Check 2 or check 3 stops her, because she cannot sign in as Alice.

The refusal itself is an ordinary OAuth error from the token endpoint, the address where applications
ask FoxAuth for tokens. Repo Helper asks for the person’s GitHub token with a token exchange (RFC
8693): it sends the person’s access token and names GitHub as the audience. Abridged, the exchange
looks like this when the person has not connected GitHub yet:

POST /token HTTP/1.1

Authorization: Basic …

Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange

&subject_token=…the person's access token…

&subject_token_type=urn:ietf:params:oauth:token-type:access_token

&requested_token_type=urn:ietf:params:oauth:token-type:access_token

&audience=…GitHub's identifier in this project…

HTTP/1.1 400 Bad Request

Content-Type: application/json

Cache-Control: no-store

{

"error": "invalid_grant",

"error_description": "the person has not connected a GitHub account",

"reason": "connect_required",

"connect_uri": "https://auth.example.com/account/services/link/q3Jx…",

"connect_id": "c6m2…",

"connect_expires_in": 1800,

"connect_message": "Open this link to connect your GitHub account and let Repo Helper use it."

}

connect_expires_in is in seconds: 1800 is the thirty-minute lifetime discussed below.

Often the program that asks for the GitHub token is not the agent itself but an MCP server the agent
calls. That MCP server passes the link on unchanged. For a client on the 2025-11-25 version of MCP, it
fails the tool call with error code -32042. That code means: show the person these addresses, then try
again. connect_id becomes the elicitationId:

{

"code": -32042,

"message": "Open this link to connect your GitHub account and let Repo Helper use it.",

"data": {

"elicitations": [

{

"mode": "url",

"elicitationId": "c6m2…",

"url": "https://auth.example.com/account/services/link/q3Jx…",

"message": "Open this link to connect your GitHub account and let Repo Helper use it."

}

]

}

}

The link carries no sign-in, so only its own person can use it

The code at the end of the link is 32 random bytes, far too many possibilities to guess. The code
only points to a record on the server. The record says which project, which person, which agent and
which service the link is for, and whether it asks to connect, to allow or to reconnect. The code does
not sign anyone in, and it says nothing about the person.

The MCP specification forbids putting a credential in the link. An address shown this way must not
contain credentials, and it must not be “pre-authenticated”: opening it must not by itself sign anyone
in as the person.

A code that only points to a record is what makes check 3 possible. A pre-authenticated link would
carry Mallory’s signed-in session inside it, so whoever opened it would act as Mallory. A plain code
forces the page to ask the browser who is there. The answer comes from that person’s own sign-in.

A plain code also changes what a leak costs. Suppose a link is pasted into a public channel. A
stranger who opens it reaches a sign-in they cannot pass, and that is all. The link still expires
after thirty minutes, because a request nobody finishes should not wait forever. That lifetime is
housekeeping. It is not what protects the person.

The server keeps one link per person, agent and service. An agent that asks again gets the same link
while it is open. So an agent that retries does not fill the person’s chat with several links that all
still work. The link is replaced only when what it asks for changes, for example from “connect” to
“allow”; the old code then leads to the 404 page.

The agent learns the outcome by asking again

When the person has finished, the page tells them to go back to the agent. It does not redirect them
anywhere. A link that sends the browser on to an address the agent chose would be one more way to
phish.

The agent finds out the result by repeating its token request. Once the person has connected GitHub
and allowed the agent, the next request returns the token. Until then, it returns the same refusal and
the same link.

MCP moved in the same direction. The 2026-07-28
version removed the notification
that told the client an elicitation was complete. Now “the client learns the outcome of an out-of-band
interaction by retrying the original request”. So the agent needs no address for the server to call
back, and no separate status endpoint to check.

When you need this check, and when you can rely on something else

You need the check whenever the link leaves the place where you already know who the person is. That
covers chat clients, terminal agents and MCP hosts. It covers any case where the link can be copied,
forwarded, or opened on another device. In all of these, the address is the only thing that reaches
the person. So the page behind it has to find out who arrived.

You can rely on something else when the agent runs inside your own web application, and the link
never leaves the browser session it was created in. Then the session already tells you who the
person is, as in Auth0’s design. Even there, check at the end that the same user came back.

Keep a short lifetime in either case. It clears away requests nobody finished. It just does not keep
the wrong person out.

The check also has a limit. It proves who the person is in FoxAuth. It does not prove which GitHub
account that person picks. If the person’s browser is still signed in to someone else’s GitHub, for
example on a shared computer, the person can connect that account without noticing. Their account
page lists each connected account by its name at GitHub, so they can spot the mistake there. No check
on the server can spot it for them.

What I have not done yet: run this against a real MCP client that displays URL elicitations. The
server side is covered by tests over HTTP and was walked through in a browser. Turning the refusal into
an elicitation takes a few lines in your own MCP server. But I have not yet watched a client show the
link to a person and then retry by itself.

To set this up, Connected services describes
the refusal and the link in full, including how to relay it to both versions of MCP. If you are new
to MCP authorization, Your MCP server must check who a token is
for covers the token side.

All articlesTry it yourself
