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
| Provider | How it completes | Start with |
|---|---|---|
siwe (Ethereum wallet) | The wallet signs a message; POST /auth/verify returns tokens | POST /auth/start returns a nonce |
siws (Solana wallet) | The wallet signs a Solana message; POST /auth/verify returns tokens | POST /auth/start returns a nonce |
google, twitter, apple | sufwan-local-test redirects back to your redirectUrl with ?code=; POST /auth/token/exchange returns tokens | POST /auth/start returns a redirect url |
email | The user opens a magic link that redirects back with ?code=; exchange it the same way | POST /auth/start sends the email |
privy | POST /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.
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
| Token | Use | Lifetime |
|---|---|---|
accessToken | Authorization: Bearer on user-tier calls | Short-lived. Refresh it when a call returns 401. |
refreshToken | Body of POST /auth/token/refresh | Longer-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.
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());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
| Status | Meaning |
|---|---|
401 with code: "unregistered_domain" | The wallet message domain is not an allowed origin. |
401 | Bad signature, reused or expired nonce, or an invalid Privy token. |
200 with type: "challenge_required" on POST /auth/start | Turnstile. Resubmit that call with turnstileToken. See Bot protection. |
403 on POST /auth/start | The Turnstile token was rejected. |
403 on POST /auth/verify | Testing mode already has 20 users, so a new user is refused. See Base URL, app IDs & API keys. |
429 | Too 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.