Account Linking
Link additional auth providers to an already signed-in user
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 sufwan-local-test account. If you need to link an sufwan-local-test user to your own partner-side user ID, use Partner External ID Linking.
A user who is already signed in (e.g. with a wallet) can attach more providers — Google, Twitter,
Apple, email, or an additional wallet (SIWE/SIWS) — to the same account. Because the caller is
already authenticated, linking is a distinct flow from sign-in: it runs against
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
client.linkAccount({ provider, redirectUrl }) → POST /users/me/link-account/start
→ redirect the browser to the provider
→ provider calls back to sufwan-local-test, which redirects to your redirectUrl?link_confirm_token=…
client.linkAccountConfirm(token) → POST /users/me/link-account/confirm
→ the Account row is written to the current userUse linkAccount(), not authStart(). authStart() starts a fresh sign-in and cannot attach
a provider to the signed-in principal — pointing it at an authenticated session produces a
competing redirect, not a link.
Example: link Google to a wallet account
// User is already signed in with SIWE
console.log(client.isAuthenticated); // true
// Start the link — redirectUrl is REQUIRED and must be an allowed origin for your app
const response = await client.linkAccount({
provider: "google",
redirectUrl: "https://yourapp.com/auth/callback",
});
if (response.type === "redirect") {
window.location.href = response.url; // send the user to Google
}OAuth start must run in the browser. The response sets an HttpOnly proof cookie the callback
requires (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:
// On the redirect target page
const token = new URLSearchParams(window.location.search).get("link_confirm_token");
if (token) {
await client.linkAccountConfirm(token);
}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.
// Anywhere under <AggAuthProvider>, kicking off the link:
import { useLinkAccount } from "@agg-build/hooks";
const { startLink, isLoading, error } = useLinkAccount();
await startLink({ provider: "google", redirectUrl: window.location.href });
// …provider round-trip… on return, AggAuthProvider auto-confirms.The page your redirectUrl lands on must have <AggAuthProvider> mounted (or call
useAggAuthCallback() on a dedicated callback
route). This is the same requirement as sign-in OAuth — if the auth provider isn't mounted where
the redirect returns, nothing consumes link_confirm_token and the link silently never completes.
Email linking
Email uses a magic link instead of an OAuth redirect. The result of linkAccount is a
magic_link acknowledgement, not a redirect — tell the user to check their inbox:
const response = await client.linkAccount({
provider: "email",
email: "user@example.com",
redirectUrl: "https://yourapp.com/auth/callback",
});
// response = { type: "magic_link", success: true } — no redirect here.When the user clicks the emailed link, sufwan-local-test redirects them to your 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 sufwan-local-test
user, the confirm call rejects with HTTP 409 — catch it and tell the user the account is
already in use elsewhere.
try {
await client.linkAccountConfirm(token);
} catch (err) {
if (err.status === 409) {
// This identity is linked to another account.
}
}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)
A wallet-only user — signed in with SIWE, no username, no OAuth provider linked — has none of the three, so every wallet link attempt 400s until one exists.
Mitigation: make sure a username is set before calling 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.
// 1. Start the link — `address` is the wallet that will sign.
const response = await client.linkAccount({
provider: "siwe", // or "siws" for Solana
address: walletAddress,
});
// response = { type: "wallet_challenge", message }// 2. Have the user sign `message` EXACTLY as returned. Do not reconstruct or edit it.
const signature = await signMessage(response.message);
// 3. Confirm with the message + signature.
await client.linkAccountConfirm({
kind: "wallet_signature",
message: response.message,
signature,
});Sign the message exactly as returned — the server compares it byte-for-byte at confirm, so any
client-side reconstruction or edit fails. The message names the signed-in account (username, masked
email, or a linked handle) so the user can verify it is their own before signing.
Wallet confirm returns the same statuses as OAuth/email: 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 sufwan-local-test 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
const profile = await client.getCurrentUser();
profile.accounts.forEach((account) => {
console.log(`${account.provider}: ${account.providerAccountId}`);
});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.
await client.disconnectAccount("google");