BotanaryBotanary
Auth

Authentication

How a Privy auth token gets exchanged for a Botanary session token, and what that token actually authorizes.

Botanary authentication is a two-party handoff: Privy verifies who you are and holds your signer, and the backend issues a session token that identifies your requests without ever touching a key.

The scheme

The OpenAPI contract declares one security scheme, sessionToken: an HTTP bearer scheme with an opaque bearer format. Its own description spells out what it is and isn't - "Botanary session token from POST /auth/session. Not a key - a revocable session bearer." That scheme is required globally by default; a small number of routes (session creation itself) explicitly override that requirement so they can be called with no session yet.

Exchanging a Privy token for a session

POST /auth/session verifies a Privy-issued auth token and returns a Botanary session context plus a bearer token. The description on this endpoint is explicit about what the backend does and doesn't see: "The API never receives, stores, or reconstructs any private key - Privy holds the signer (user-owned 2-of-2)." The request body is just { privyToken }; a successful response is a SessionContext.

// The exchange, client-side
const privyToken = await getAccessToken();
const { data, error } = await fetchClient.POST('/auth/session', {
  body: { privyToken: privyToken ?? '' },
});
if (!error && data) {
  setSessionToken(data.sessionToken);
}

Server-side, this is SessionService.create(privyToken): it rejects an empty token outright, verifies the token against Privy, resolves the caller's accountId by signer address (or leaves it null if none exists yet), narrows chains to the set the account can actually use, stamps issuedAt and a one-day expiresAt, and persists the session as not locked.

A SessionContext carries: sessionToken, accountId (nullable - null before your first account exists), signerAddress, chains[], issuedAt, expiresAt, and locked (the session-lock state; see Sessions). Its own description underlines the same no-key guarantee: "the API never holds a key... Contains no human PII."

GET /auth/session returns your current context; DELETE /auth/session ends it - see Sessions for what that means in practice.

How every request gets checked

A global SessionGuard runs after the rate limiter on every request (see Architecture). It skips routes explicitly marked public; otherwise it pulls the Authorization: Bearer <token> header, looks the token up, and returns 401 if it's missing, unknown, or expired - only then does the request reach its handler.

Where the key actually lives

The frontend never asks you for a private key because it never has one to ask for. On login, it resolves or creates a Privy embedded EOA and installs a signer callback that asks that wallet to sign a digest - "the key stays in Privy's TEE." Once you're authenticated with Privy, the frontend fetches a Privy access token and exchanges it for a Botanary session token using the call shown above.

What gets stored client-side afterward is deliberately thin: a module-level session token and a signer callback, not a key - "only an opaque bearer token and a callback that asks the Privy embedded wallet to sign." Every subsequent request attaches that token as a bearer header:

fetchClient.use({
  onRequest({ request }) {
    const token = MOCKING ? 'dev-session' : getSessionToken();
    if (token) request.headers.set('Authorization', `Bearer ${token}`);
    return request;
  },
});

'dev-session' is a fixed fixture value used only when the frontend is running against MSW mocks (see Quickstart) - it's never a real token, and it never reaches a real backend.

Privy is a signer, never an authority. It resolves who you are and holds your key; every decision about what an action is allowed to do is enforced by the backend and the account's own on-chain rules, not by Privy.

Real Privy auth is only wired up on the frontend when the app isn't running in mock mode and a Privy app ID is configured - it's never active under the MSW test harness, so local development never depends on a live Privy connection unless you opt into it.

On this page