---
title: "Authentication"
description: "Sign users in with SIWE, SIWS (Solana), Google, Twitter, Apple, or Email magic link"
---

> **For AI agents:** the complete documentation index is at [llms.txt](/llms.txt). Append `.md` to any page URL for its markdown version.

<Info>
  Start with the [Setup Guide](/api/setup) if you still need to wire `createAggClient`,
  `AggProvider`, or `AggAuthProvider`. After sign-in, see [Token Refresh](/recipes/token-refresh)
  for session renewal, [Account Linking](/recipes/account-linking) for adding additional
  providers, and [Partner External ID Linking](/recipes/external-id-linking) if you need to attach
  your own internal user ID to the AGG profile.
</Info>

AGG supports six authentication providers:

- `siwe` for Ethereum wallets
- `siws` for Solana wallets
- `google` for Google OAuth
- `twitter` for X OAuth
- `apple` for Apple Sign In
- `email` for 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:

```json
{ "type": "challenge_required", "siteKey": "0x4AAAAAAA..." }
```

Your flow becomes:

1. Call `client.authStart({ provider: "siwe" })`.
2. If the response type is `challenge_required`, render the Cloudflare Turnstile widget using the
   returned `siteKey`.
3. On solve, resubmit `client.authStart({ provider: "siwe", turnstileToken: "<cf-token>" })`.
4. The server verifies the token against its linked widget and returns the normal `nonce` /
   `magic_link` response.
5. An invalid or reused token returns `403` — retry with a fresh Turnstile solve.


```typescript
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.

```typescript
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.

```typescript
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:

1. `client.authStart({ provider: "siws" })`
2. `client.buildSiwsMessage(...)`
3. `wallet.signMessage(new TextEncoder().encode(message))`
4. `bs58.encode(signature)`
5. `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.

<Tabs>
  <Tab title="Google">
    <div key="google" className="[&>p]:mb-4 [&>p:last-child]:mb-0">
      ```typescript
      const response = await client.authStart({
        provider: "google",
        redirectUrl: "https://yourapp.com/auth/callback",
      });

      if (response.type === "redirect") {
        window.location.assign(response.url);
      }
      ```
    </div>
  </Tab>

  <Tab title="Twitter">
    <div key="twitter" className="[&>p]:mb-4 [&>p:last-child]:mb-0">
      ```typescript
      const response = await client.authStart({
        provider: "twitter",
        redirectUrl: "https://yourapp.com/auth/callback",
      });

      if (response.type === "redirect") {
        window.location.assign(response.url);
      }
      ```
    </div>
  </Tab>

  <Tab title="Apple">
    <div key="apple" className="[&>p]:mb-4 [&>p:last-child]:mb-0">
      ```typescript
      const response = await client.authStart({
        provider: "apple",
        redirectUrl: "https://yourapp.com/auth/callback",
      });

      if (response.type === "redirect") {
        window.location.assign(response.url);
      }
      ```
    </div>
  </Tab>

  <Tab title="Email">
    <div key="email" className="[&>p]:mb-4 [&>p:last-child]:mb-0">
      ```typescript
      await client.authStart({
        provider: "email",
        email: "user@example.com",
        redirectUrl: "https://yourapp.com/auth/callback",
      });
      ```
    </div>
  </Tab>
</Tabs>

After the user authenticates, AGG redirects back with a one-time auth code:

```text
https://yourapp.com/auth/callback?code=a1b2c3d4...
```

Exchange that code for tokens on the redirect target page:

```typescript
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](/components/auth/connect-button) for the
component surface shown below.

### Mixed providers

```tsx
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](/components/auth/connect-button).

### Dedicated callback pages

If your app uses a standalone callback route, use `useAggAuthCallback()`:

```tsx
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

```tsx
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

```tsx
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 |

<Note>
  Set `persistSession: false` to opt out of the hint entirely.
</Note>

### 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()`:

```ts
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

```bash
# 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
```

## Related

<Columns cols={2}>
  <Card title="Setup Guide" icon="gear" href="/api/setup">
    Wire the base client, providers, and WebSocket connection first.
  </Card>
  <Card title="Token Refresh" icon="key" href="/recipes/token-refresh">
    Renew access tokens and recover gracefully from session expiry.
  </Card>
  <Card title="Account Linking" icon="code" href="/recipes/account-linking">
    Connect additional OAuth providers to the current user profile.
  </Card>
  <Card title="User Notifications" icon="brackets-curly" href="/recipes/websocket-notifications">
    Reuse the same session for authenticated WebSocket events.
  </Card>
  <Card title="Bot Protection" icon="shield-halved" href="/recipes/bot-protection">
    Render and verify Cloudflare Turnstile challenges during sign-in.
  </Card>
</Columns>
