Skip to content

Auth flow

AuthService issues two kinds of bearer session tokens, and MakerService additionally accepts a static organization API key. Pick the path that matches the caller:

PathHeaderMinted byLifetimeAccepted by
Keypair sessionauthorization: Bearer <token>Authenticate (signed nonce)6 hTxService, MakerService, EscalationService
Organization sessionauthorization: Bearer <token>VerifyLoginCode (passwordless email)7 dMakerService, EscalationService
Organization API keyx-api-key: <key>static, hashed server-siden/aMakerService, EscalationService

MarketDataService, StatsService, HistoricalService, and every AuthService RPC are unauthenticated. MakerService and EscalationService additionally require that the resolved maker_id be registered in the server's organizations table. maker_id provisioning is done by Flint Labs — contact us if you haven't been set up yet.

MakerService.GetSdkCredentials — the RPC behind the dashboard's registry token card — requires an organization session; demo sessions are rejected with PERMISSION_DENIED. The returned token is stable and re-fetchable: every call returns the same credentials, so there is nothing to store.

The keypair path is multi-step and signed:

  1. Challenge(pubkey) — server returns a single-use random nonce bound to your pubkey, valid for a short TTL.
  2. Sign AUTH_DOMAIN_PREFIX || nonce (the raw bytes — no extra encoding) with the keypair owning the pubkey. The constant is b"SWEETSPOT-AUTH-V1:".
  3. Authenticate(pubkey, signature) — server verifies the signature, maps the pubkey to a maker_id via its registry, and returns a session_token plus an expires_at. If the pubkey is a quoting authority for more than one maker (a shared quoting key), pass the optional maker_id to pick which one — see Shared quoting keys.
  4. Use the token as authorization: Bearer <token> on subsequent RPCs.
  5. Re-auth before expires_at. The SDK helpers cache the token and refresh skew ahead of expiry automatically.

The passwordless email path is two RPCs:

  1. RequestLoginCode(email) — server emails a 6-digit code, rate-limited to one request per email per minute.
  2. VerifyLoginCode(email, code) — server returns a 7-day session_token + maker_id + expires_at.

The Rust and Python SDKs each ship an AuthFlow helper that drives the keypair handshake — the same shape in both (token / refresh / revoke, refreshing skew ahead of expiry). The TypeScript SDK ships an AuthFlow for the email path and an apiKeyInterceptor for the API key path; the keypair signed-nonce flow is not exposed in TS (use a wallet adapter directly, or the Rust/Python SDK).

Wallet signer

The signer is intentionally narrow: produce a 64-byte ed25519 signature over an arbitrary byte slice, and return the corresponding 32-byte public key. This lets you back the same AuthFlow with a keypair file, hardware wallet, remote signer, or browser wallet adapter.

rust
// The Rust SDK takes any `solana_sdk::signer::Signer` (`Keypair`,
// hardware wallet, presigner, remote signer) directly — typed as
// `Wallet = Arc<dyn Signer + Send + Sync>`.
use std::sync::Arc;
use solana_sdk::signer::keypair::{read_keypair_file, Keypair};
use flint_api_client::api::auth::Wallet;

// Local keypair file.
let kp: Keypair = read_keypair_file("/path/to/id.json")
    .map_err(|e| anyhow::anyhow!("{e}"))?;
let wallet: Wallet = Arc::new(kp);

// Hardware wallets / remote signers: implement `solana_sdk::signer::Signer`
// (`pubkey()`, `try_sign_message(...)`) and wrap with `Arc::new(...)`.
python
from solders.keypair import Keypair

from flint import Client

keypair = Keypair.from_json(open("/path/to/id.json").read())
client = Client(keypair=keypair)

Shared quoting keys

A quoting key is usually a quoting authority for a single maker, in which case the server resolves the maker_id for you and nothing extra is needed. When the same key is registered as a quoting authority for more than one maker, pass the maker you want to authenticate as — the server scopes the session (and its rate-limit bucket) to exactly that maker, and rejects a maker_id the key isn't an authority for with UNAUTHENTICATED. If you omit maker_id for a shared key the server does not fail: it defaults to the lowest of the key's maker ids and logs a warning, so set maker_id to land on the maker you intend.

rust
// On the client builder...
let client = Client::builder()
    .wallet(wallet)
    .maker_id(3) // only needed for a key shared across makers
    .build()
    .await?;

// Or directly on the AuthFlow.
let auth = AuthFlow::new(channel, wallet).with_maker_id(3);
python
client = Client(
    keypair=Keypair.from_json(open("/path/to/id.json").read()),
    maker_id=3,  # only needed for a key shared across makers
)

Refresh strategy

spawn_refresh_loop() returns a tokio::JoinHandle and defaults to a 30 s skew before expiry. Override with with_skew.token() refreshes the cached session before expiry. Configure the skew with skew_seconds.

Revoke

If you need to invalidate a token before its natural expiration (e.g. on logout), call revoke():

rust
auth.revoke().await?;
python
await client.revoke()

The cached session is dropped; subsequent calls re-authenticate from scratch.

Errors

AuthService.Authenticate returns UNAUTHENTICATED when:

  • The pubkey has no outstanding nonce.
  • The nonce has expired (TTL exceeded).
  • The signature is invalid for the nonce.
  • The pubkey is not registered as a quoting authority.
  • A maker_id was supplied that the pubkey is not a quoting authority for.

A pubkey that is a quoting authority for more than one maker does not error when maker_id is omitted — the server defaults to the lowest of its maker ids and logs a warning (see Shared quoting keys).

The SDK surfaces these as AuthError::Servicegrpc.RpcError, with the gRPC status preserved so callers can branch on the code.

Python: keypair signed-nonce

The Python AuthFlow drives the same handshake. Client.authenticate() runs it once; token() returns the cached session and re-authenticates skew seconds (default 30) before expires_at; revoke() invalidates the token server-side and drops the cache. Omit the keypair when constructing a Python Client for public market-data, stats, or historical calls. Pass a keypair for maker or tx RPCs. Endpoints default to mainnet — see Overriding the endpoint.

python
import asyncio

from solders.keypair import Keypair
from flint import Client


async def main() -> None:
    client = Client(
        keypair=Keypair.from_json(open("/path/to/id.json").read()),
    )

    session = await client.authenticate()          # Challenge -> sign -> Authenticate
    print("authenticated as maker_id", session.maker_id)

    await client.token()                            # cached; re-auths near expiry
    await client.revoke()                           # invalidate + drop the cache
    await client.close()


asyncio.run(main())

To wire it up by hand, construct AuthFlow over an AuthServiceStub and stamp await auth.auth_metadata() ([("authorization", "Bearer <token>")]) on each authed RPC. Pass skew_seconds= to tune the refresh window.

TypeScript: passwordless email login

Browser apps authenticate against MakerService with the passwordless email flow. AuthFlow drives the two RPCs, caches the resulting session, and produces an interceptor that stamps authorization: Bearer <token> on every outbound call.

ts
import {
  AuthFlow,
  createMakerClient,
} from "@superis-labs/flint-api-client";

// baseUrl defaults to the mainnet public endpoint.
const auth = new AuthFlow({
  storage: globalThis.localStorage,        // optional — survives reload
  onExpired: (session) => router.push("/login"),
});

await auth.requestLoginCode("trader@example.com");
// …prompt the user…
const session = await auth.verifyLoginCode("trader@example.com", code);
console.log("logged in as maker", session.makerId);

// baseUrl defaults to the mainnet maker endpoint.
const maker = createMakerClient({ auth });
const { balances } = await maker.getBalance({ spotIds: [] });

Session persistence

AuthFlow accepts any localStorage-shaped store (getItem / setItem / removeItem). Pass globalThis.localStorage in a browser, globalThis.sessionStorage for tab-scoped persistence, or any custom adapter (IndexedDB-backed, cookie-backed, in-memory) that implements the same three methods.

ts
const auth = new AuthFlow({
  storage: globalThis.localStorage,
  storageKey: "sweetspot.session",  // default
});

On construction AuthFlow rehydrates a session from storage if one is present. On verifyLoginCode() it overwrites; on clear() and on detected expiry it removes the entry.

Expiry callback

AuthFlow does not auto-refresh — organization sessions are minted by a user-driven email flow, not a signer. Subscribe to onExpired and re-prompt:

ts
const auth = new AuthFlow({
  onExpired: () => {
    // Storage already cleared. Send the user back to the login form.
    router.push("/login");
  },
  expirySkewMs: 60_000,  // treat as expired this long before expiresAt; default 30s
});

The callback fires once per session, inside the interceptor, just before it throws GrpcError(Unauthenticated) — wrapping for await loops will unwind on the next emission.

Organization API key

For server-side Node consumers (or any context where embedding a long-lived secret in client JS is acceptable), pass an API key directly via apiKeyInterceptor:

ts
import {
  apiKeyInterceptor,
  createMakerClient,
} from "@superis-labs/flint-api-client";

const maker = createMakerClient({
  auth: apiKeyInterceptor(process.env.SUPERIS_API_KEY!),
});

API keys are hashed server-side (SHA-256) against organizations.api_key_hashes. They are accepted by MakerService, but not by TxService, and never expire.

Browser apps

Don't ship an API key in browser JavaScript. Anyone viewing the page can read it and impersonate the organization. Use AuthFlow instead; API keys belong on a trusted server.

Built on Solana