Authentication
Sign users in with SIWE, SIWS (Solana), Google, Twitter, Apple, or Email magic link
Start with the Setup Guide if you still need to wire createAggClient,
AggProvider, or AggAuthProvider. After sign-in, see Token Refresh
for session renewal, Account Linking for adding additional
providers, and Partner External ID Linking if you need to attach
your own internal user ID to the AGG profile.
AGG supports six authentication providers:
siwefor Ethereum walletssiwsfor Solana walletsgooglefor Google OAuthtwitterfor X OAuthapplefor Apple Sign Inemailfor magic-link email sign-in
How It Works
All flows start with client.authStart(...), but they complete in two different ways:
| Flow | Providers | Completion |
|---|---|---|
| Message signing | siwe, siws | Call client.verify({ message, signature }) to receive tokens directly. |
| Redirect / magic link | google, twitter, apple, email | AGG redirects back with ?code=...; call client.exchangeAuthCode(code). |
Bot Protection (challenge_required)
For wallet (siwe, siws) and email (email) providers, AGG enforces an optional Cloudflare
Turnstile challenge when your app crosses its configured user-count threshold. OAuth providers
(google, twitter, apple) bypass this layer — the identity provider is already the human proof.
When the challenge is active, POST /auth/start may return an extra response type:
{ "type": "challenge_required", "siteKey": "0x4AAAAAAA..." }
Your flow becomes:
- Call
client.authStart({ provider: "siwe" }). - If the response type is
challenge_required, render the Cloudflare Turnstile widget using the returnedsiteKey. - On solve, resubmit
client.authStart({ provider: "siwe", turnstileToken: "<cf-token>" }). - The server verifies the token against its linked widget and returns the normal
nonce/magic_linkresponse. - An invalid or reused token returns
403— retry with a fresh Turnstile solve.
const start = await client.authStart({ provider: "siwe" });
if (start.type === "challenge_required") {
const turnstileToken = await renderTurnstileWidget(start.siteKey); // your UI helper
const retried = await client.authStart({ provider: "siwe", turnstileToken });
// retried.type === "nonce" — continue the SIWE flow below
}
SDK: SIWE
SIWE is fully client-side and does not redirect.
import { createAggClient } from "@agg-build/sdk";
const client = createAggClient({
baseUrl: "https://api.agg.market",
appId: "your-app-id",
});
const { nonce } = await client.authStart({ provider: "siwe" });
const message = client.buildSiweMessage({
address: walletAddress,
chainId: 1,
nonce,
domain: window.location.host,
uri: window.location.origin,
});
const signature = await signMessage({ message }); // wagmi, viem, ethers, etc.
const { accessToken, refreshToken, user } = await client.verify({ message, signature });
If your browser app wants the refresh token set as an HttpOnly cookie instead, configure the SDK
client with authDelivery: "cookie-refresh". In that mode, verify() and exchangeAuthCode()
may omit refreshToken from the JSON response. That cookie stays on the AGG API host and is scoped
to /auth routes.
SDK: SIWS
SIWS is also fully client-side and does not redirect.
import bs58 from "bs58";
import { createAggClient } from "@agg-build/sdk";
const client = createAggClient({
baseUrl: "https://api.agg.market",
appId: "your-app-id",
});
const { nonce } = await client.authStart({ provider: "siws" });
const message = client.buildSiwsMessage({
address: walletPublicKey.toBase58(),
chainId: "mainnet",
nonce,
domain: window.location.host,
uri: window.location.origin,
});
const signedBytes = await wallet.signMessage(new TextEncoder().encode(message));
const signature = bs58.encode(signedBytes);
const { accessToken, refreshToken, user } = await client.verify({ message, signature });
SIWS with Ledger
Ledger is not a separate AGG auth flow. It uses the exact same SIWS contract:
client.authStart({ provider: "siws" })client.buildSiwsMessage(...)wallet.signMessage(new TextEncoder().encode(message))bs58.encode(signature)client.verify({ message, signature })
This matters because Ledger supports signMessage() but not Phantom's wallet-specific signIn()
helper. AGG's SIWS recipe stays Ledger-compatible by building the message in the dapp and signing
the raw UTF-8 bytes directly.
SDK: Google / Twitter / Apple / Email
These four providers all use the same redirect-based flow.
const response = await client.authStart({
provider: "google",
redirectUrl: "https://yourapp.com/auth/callback",
});
if (response.type === "redirect") {
window.location.assign(response.url);
}After the user authenticates, AGG redirects back with a one-time auth code:
https://yourapp.com/auth/callback?code=a1b2c3d4...
Exchange that code for tokens on the redirect target page:
async function handleAuthRedirect() {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
if (!code) return;
const { accessToken, refreshToken, user } = await client.exchangeAuthCode(code);
window.history.replaceState(null, "", window.location.pathname);
}
handleAuthRedirect();
The auth code is single-use and expires quickly, so exchange it immediately.
React: recommended @agg-build/auth
Use @agg-build/auth when you want AGG's connect/sign-in UI without forcing wallet dependencies
into @agg-build/ui. See the Connect Button reference for the
component surface shown below.
Mixed providers
import { AggProvider } from "@agg-build/hooks";
import { createAggClient } from "@agg-build/sdk";
import {
AggAuthProvider,
ConnectButton,
createEmailAuthMethod,
createGoogleAuthMethod,
} from "@agg-build/auth";
import { useSiweAuthMethod } from "@agg-build/auth/siwe";
import { WagmiProvider } from "wagmi";
import { wagmiConfig } from "./wagmi-config";
const client = createAggClient({
baseUrl: "https://api.agg.market",
appId: "your-app-id",
});
function AuthButton() {
const siwe = useSiweAuthMethod({
statement: "Sign in to AGG",
});
return (
<AggAuthProvider methods={[siwe, createGoogleAuthMethod(), createEmailAuthMethod()]}>
<ConnectButton />
</AggAuthProvider>
);
}
export function App() {
return (
<WagmiProvider config={wagmiConfig}>
<AggProvider client={client}>
<AuthButton />
</AggProvider>
</WagmiProvider>
);
}
Browse the live Connect Button reference.
Dedicated callback pages
If your app uses a standalone callback route, use useAggAuthCallback():
import { useAggAuthCallback } from "@agg-build/auth";
export function AuthCallbackPage() {
const { error, isHandled, isHandling, user } = useAggAuthCallback();
if (isHandling) return <p>Finishing sign-in...</p>;
if (error) return <p>{error.message}</p>;
if (isHandled) return <p>Signed in as {user?.id}</p>;
return <p>No AGG auth callback data was found.</p>;
}
React: useAggAuth
useAggAuth keeps the documented wallet ergonomics for custom UIs.
Ethereum
import { useAggAuth } from "@agg-build/hooks";
import { useAccount, useSignMessage } from "wagmi";
function SignIn() {
const { address, chainId } = useAccount();
const { signMessageAsync } = useSignMessage();
const { signIn, signOut, isAuthenticated, user, isLoading } = useAggAuth({
address,
chainId,
signMessage: async (message) => signMessageAsync({ message }),
});
if (isAuthenticated) {
return (
<div>
<p>Signed in as {user.id}</p>
<button onClick={signOut}>Sign Out</button>
</div>
);
}
return (
<button onClick={() => signIn("Sign in to AGG")} disabled={isLoading}>
Sign in with Ethereum
</button>
);
}
Solana
import bs58 from "bs58";
import { useWallet } from "@solana/wallet-adapter-react";
import { useAggAuth } from "@agg-build/hooks";
function SolanaSignIn() {
const { publicKey, signMessage } = useWallet();
const { signIn, isLoading } = useAggAuth({
address: publicKey?.toBase58(),
chain: "solana",
chainId: "mainnet",
signMessage: async (message) => {
const signedBytes = await signMessage!(new TextEncoder().encode(message));
return bs58.encode(signedBytes);
},
});
return (
<button onClick={() => signIn("Sign in to AGG")} disabled={isLoading}>
Sign in with Solana
</button>
);
}
Token storage
Tokens are never written to storage. They live in process memory only. What the SDK persists to
localStorage (keyed by appId) is a non-sensitive hint — the user's id, username, avatar and
wallet address, plus a wasAuthenticated flag — used to bootstrap your UI without a flash of
signed-out state. Restoring that hint sets authStatus to "unknown", not "authenticated".
What survives a reload therefore depends on authDelivery:
| Mode | Refresh token | Survives reload |
|---|---|---|
"body" (default) | Returned in the verify() response body | No — you store it |
"cookie-refresh" | HttpOnly cookie set by the API | Yes — silent refresh on cold start |
Set persistSession: false to opt out of the hint entirely.
React Native
localStorage does not exist in React Native, so nothing is persisted and isAuthenticated flips
to false on every app restart. cookie-refresh is not the answer either — HttpOnly cookies don't
survive there. Keep the default "body" mode, persist the refresh token yourself, and seed it back
on boot with setSession():
import * as SecureStore from "expo-secure-store";
// After sign-in — verify() returns { accessToken, refreshToken?, user }.
const session = await client.verify({ message, signature });
if (session.refreshToken) {
await SecureStore.setItemAsync("agg_refresh", session.refreshToken);
}
// On app boot, before your first authenticated call.
const refreshToken = await SecureStore.getItemAsync("agg_refresh");
if (refreshToken) {
client.setSession({ accessToken: "", refreshToken });
await client.refreshAccessToken(); // exchanges it for a fresh access token
}
Store the refresh token in the platform keystore (Keychain / Keystore via expo-secure-store or
react-native-keychain) — not AsyncStorage, which is unencrypted.
Installation matrix
# Base React packages
npm install @agg-build/sdk @agg-build/hooks @agg-build/ui @agg-build/auth
# Add only the providers you use
npm install wagmi
npm install @solana/wallet-adapter-react bs58