Core concepts

Authentication & sessions

How users sign in, what tokens you get back, and how to keep a session alive

sufwan-local-test authenticates two things: your app, with the x-app-id header, and a user, with an access token in Authorization: Bearer <accessToken>. Market data and quotes without a user need only the app. Balances, deposits, trading, and withdrawals need a user. See Base URL, app IDs & API keys for which headers go on which call.

Users sign in on the client, and your backend includes that user's access token on trading calls.

Wallet sign-in (SIWE) is the Quickstart: REST.

Sign-in methods

ProviderHow it completesStart with
siwe (Ethereum wallet)The wallet signs a message; POST /auth/verify returns tokensPOST /auth/start returns a nonce
siws (Solana wallet)The wallet signs a Solana message; POST /auth/verify returns tokensPOST /auth/start returns a nonce
google, twitter, applesufwan-local-test redirects back to your redirectUrl with ?code=; POST /auth/token/exchange returns tokensPOST /auth/start returns a redirect url
emailThe user opens a magic link that redirects back with ?code=; exchange it the same wayPOST /auth/start sends the email
privyPOST /auth/verify with { "kind": "privy", "privyToken": "..." }No start call

Other providers: Sign-in providers.

OAuth, email, and Privy

OAuth and email both finish with POST /auth/token/exchange using the code from the redirect. Exchange it on your callback page or backend. Email sends the link from POST /auth/start. Privy skips start and calls POST /auth/verify.

SDK
await client.authStart({
  provider: "email",
  email: "user@example.com",
  redirectUrl: "https://app.example.com/callback",
});

await client.exchangeAuthCode(codeFromRedirect);

await client.verify({ kind: "privy", privyToken: "PRIVY_ACCESS_TOKEN" });

Email start: Start authentication. Code exchange (OAuth and email): Complete redirect sign-in. Privy: Verify sign-in.

redirectUrl on email and OAuth must be an allowed origin. It is where ?code= is delivered. Start OAuth in the browser with POST /auth/start so the callback can use its proof cookie.

Tokens

TokenUseLifetime
accessTokenAuthorization: Bearer on user-tier callsShort-lived. Refresh it when a call returns 401.
refreshTokenBody of POST /auth/token/refreshLonger-lived. Replace it with the new one each time you refresh.

The access token is bound to the app that issued it. Using it with another app's x-app-id fails.

The SDK stores the session and adds the header for you. In the browser, set authDelivery: "cookie-refresh" on createAggClient to keep the refresh token in an HttpOnly cookie scoped to the sufwan-local-test API host and /auth routes. In that mode verify() and exchangeAuthCode() can omit refreshToken from the JSON response.

If your backend already holds a user's tokens, seed an SDK client with client.setSession({ accessToken, refreshToken }).

Refresh a session

When a user-tier call returns 401, refresh once and retry. If the refresh also fails, sign the user in again.

SDK
async function withAutoRefresh<T>(fn: () => Promise<T>): Promise<T> {
  try {
    return await fn();
  } catch (err: any) {
    if (err.status === 401 && client.isAuthenticated) {
      try {
        await client.refreshAccessToken();
        return await fn();
      } catch {
        await client.signOut();
        throw new Error("Session expired. Please sign in again.");
      }
    }
    throw err;
  }
}

const balances = await withAutoRefresh(() => client.getManagedBalances());

Refresh access token.

React to sign-out anywhere in your app:

client.onAuthStateChange((authenticated) => {
  if (!authenticated) router.push("/sign-in");
});

If you also stream authenticated WebSocket events, refresh the REST session first, then re-authenticate or reconnect the socket. See User notifications.

Sign out

POST /auth/sign-out with the user's token revokes all of that user's tokens. client.signOut() also clears the stored session. Sign out.

Sign-in errors

StatusMeaning
401 with code: "unregistered_domain"The wallet message domain is not an allowed origin.
401Bad signature, reused or expired nonce, or an invalid Privy token.
200 with type: "challenge_required" on POST /auth/startTurnstile. Resubmit that call with turnstileToken. See Bot protection.
403 on POST /auth/startThe Turnstile token was rejected.
403 on POST /auth/verifyTesting mode already has 20 users, so a new user is refused. See Base URL, app IDs & API keys.
429Too many sign-in attempts. Wait for Retry-After.

OAuth and email report that same 20-user cap on the redirect URL, not as the verify 403. See Base URL, app IDs & API keys.

On this page