Linking reuses the same redirect/callback machinery as
Authentication — but it uses a separate set of
endpoints from sign-in. Do not use
authStart() to link.This page is about linking multiple auth providers to one AGG account. If you need to link an
AGG user to your own partner-side user ID, use
Partner External ID Linking.
POST /users/me/link-account/*. OAuth/email finish with a short-lived confirm token; wallet linking
finishes with a signature over a server-issued challenge. In every case the new provider is bound to
the current principal.
The flow
Example: link Google to a wallet account
error=link_proof_invalid on the redirect if it is missing). linkAccount() stores
that cookie. The curl is the same body and headers, not a replacement for the browser call.
After the provider callback, the browser lands back on your redirectUrl with a
link_confirm_token query param. Exchange it for the linked account:
React: this is handled for you
If your app is wrapped in<AggAuthProvider> from @agg-build/auth, the confirm step runs
automatically. On mount it reads link_confirm_token from the URL, calls linkAccountConfirm(),
refreshes the user, and strips the token from the address bar — the same handler that finishes
sign-in callbacks.
Email linking
Email uses a magic link instead of an OAuth redirect. The result oflinkAccount is a
magic_link acknowledgement, not a redirect — tell the user to check their inbox:
redirectUrl with a
link_confirm_token, and the same confirm step above completes the link. The confirm call is
bound to the signed-in bearer, so the link must be opened in the browser where the user is still
authenticated.
Collisions
linkAccountConfirm() resolves to { status: "linked" } on first link and
{ status: "already_linked_same" } if that identity was already attached to the same user
(idempotent — safe to retry). If the provider identity already belongs to a different AGG
user, the confirm call rejects with HTTP 409 — catch it and tell the user the account is
already in use elsewhere.
Wallet linking (SIWE/SIWS)
A signed-in user can link an additional wallet (Ethereum or Solana) to their account. Unlike OAuth/email there is no redirect:/start returns a wallet_challenge containing a message the user
signs verbatim with their wallet, then you submit the signature to /confirm.
Prerequisite: the account needs a name for the challenge
The challenge message names the signed-in account so the user can tell it is theirs before signing. If the account has nothing to name,/start fails closed with HTTP 400
no_identity_for_link_challenge instead of issuing an anonymous challenge.
This applies to wallet linking only — OAuth and email linking never hit it, because
neither builds a message for the user to sign.
The server uses the first of these it finds:
username- a masked verified email from a linked account (
a***@example.com) - a
displayNamefrom a linked account (e.g. a Google profile name)
linkAccount(). It is the only one
of the three you can set directly. Set it with Update username
(client.updateUser({ username })). 3-30 characters; letters, numbers, and _ - .; must start
and end with a letter or number. A taken name returns 409.
There is deliberately no server-side fallback here. The identity has to be something the user
already had before the link started — a value minted during the flow would be one the
attacker holds and the victim has nothing to check against. An app-name-only statement is no
better, since a phisher running the same app produces an identical one.
linked on first link, already_linked_same
if that wallet was already attached to the same user (idempotent), and HTTP 409 if the wallet is
already linked to a different AGG user.
Auto-linking by email
If a user signs in with a provider that carries a verified email (e.g. Google) and another account already exists with that email, the accounts are linked automatically during sign-in — no explicit link step needed.Viewing linked accounts
Disconnect a provider
DELETE /users/me/accounts/{provider} removes one linked provider. {provider} is wallet,
solana_wallet, google, twitter, apple, or email — not siwe or siws. Removing the
last provider returns 400. When several accounts share that provider, the API requires query
providerAccountId and returns 400 if you omit it. disconnectAccount(provider) does not send
that query.