Skip to content

Authentication

Smart Engines never asks for a password or a private key. You authenticate by signing a one-time challenge with your wallet, which proves you control the address without ever revealing your key. This is the same model MetaMask and other Web3 wallets use.

Chain-agnostic by design — XRPL is the default

Section titled “Chain-agnostic by design — XRPL is the default”

You pick the chain — the SDK never assumes one for you. The auth types name several chains, but only two are live:

Chain Status
XRPL Live
Hedera Live
Polkadot, Stellar, Solana Coming next

For Smart Engines V3 smart-apps, XRPL is the canonical default (the HSUITE token lives on XRPL), so the fastest path is the XRPL signer helper.

import { createXrplWeb3Signer } from '@hsuite/smart-engines-sdk';
const signer = createXrplWeb3Signer(process.env.XRPL_SEED!);
// → { chain: 'xrpl', address, publicKey, signFn, wallet }

createXrplWeb3Signer takes an XRPL family seed (s...) and returns the exact { chain, address, publicKey, signFn } tuple every SDK auth flow accepts. It handles the fiddly detail that trips everyone up: the server’s verifier expects the challenge as upper-hex UTF-8 bytes signed with ripple-keypairs, not the transaction-shaped wallet.sign().

sequenceDiagram
  participant App as Your app
  participant SDK as BaasClient
  participant H as Host (via the gateway)

  App->>SDK: authenticate({ chain, walletAddress, publicKey, signFn })
  SDK->>H: POST /api/v3/baas/auth/challenge { chain, walletAddress, appId }
  H-->>SDK: { challengeId, message, expiresAt }
  SDK->>SDK: signFn(message) → signature
  SDK->>H: POST /api/v3/baas/auth/verify { challengeId, signature, publicKey }
  H-->>SDK: { token, expiresAt, walletAddress, chain, appId }
  SDK-->>App: result (token attached to the client)

BaasClient.connectToCluster resolves a cluster and returns a client; call authenticate on it to get a session in the same flow. authenticate needs the appId you passed when connecting. (SDK 8.0 retired the validator-direct SmartEngineClient.connectToNetwork one-shot — the registry publishes no validator API endpoint, so it had no public origin to connect to. See Zero-config connect.)

import { BaasClient, createXrplWeb3Signer } from '@hsuite/smart-engines-sdk';
const signer = createXrplWeb3Signer(process.env.XRPL_SEED!);
const baas = await BaasClient.connectToCluster({ network: 'testnet', appId: 'app_abc' });
const session = await baas.authenticate({
chain: signer.chain, // 'xrpl'
walletAddress: signer.address,
publicKey: signer.publicKey,
signFn: signer.signFn,
});
console.log('Authenticated until', new Date(session.expiresAt)); // expiresAt is epoch ms
// `baas` already carries session.token as the bearer for subsequent calls.

BaasClient authenticates against the host’s own challenge endpoints (/api/v3/baas/auth/challenge and /api/v3/baas/auth/verify, reached through the gateway under /host). Connect first, then authenticate:

import { BaasClient, createXrplWeb3Signer } from '@hsuite/smart-engines-sdk';
const signer = createXrplWeb3Signer(process.env.XRPL_SEED!);
const baas = await BaasClient.connectToCluster({ network: 'testnet', appId: 'app_abc' });
await baas.authenticate({
chain: signer.chain,
walletAddress: signer.address, // note: BaaS uses `walletAddress`
publicKey: signer.publicKey,
signFn: signer.signFn,
});
console.log('Authenticated:', baas.isAuthenticated()); // true

Tokens refresh themselves — you don’t have to catch expiry

Section titled “Tokens refresh themselves — you don’t have to catch expiry”

Session tokens don’t live forever. A long-running smart-app would historically die a few hours after boot: the token expired, the next call came back 401 Invalid or expired authentication token, and you were on the hook for catching it and re-authenticating.

BaasClient now does this for you. After you’ve called authenticate() once, it retains your signer and transparently re-authenticates whenever a request hits an expired token — then retries that request. You write your code as if the session never expires:

await baas.authenticate({
chain: signer.chain,
walletAddress: signer.address,
publicKey: signer.publicKey,
signFn: signer.signFn,
});
// Hours later, after the token has expired — this still just works.
// The SDK re-runs challenge→verify under the hood and retries the insert.
await baas.db.insert('users', { name: 'Alice' });

Exactly what happens on a 401:

  1. The SDK runs your signFn through a fresh challenge→verify, minting a new token.
  2. It retries the original request once with that token.
  3. If the request still fails (or the re-auth itself fails), the original 401 is surfaced to you unchanged — so a genuinely-revoked session never loops.

A few details worth knowing:

It's automatic for every sub-client

Every BaasClient sub-client (db, storage, entities, agents and the rest) inherits it. No opt-in.

It re-uses the signer

From your last authenticate() call. Nothing happens if you never authenticated — there’s no signer to refresh with.

The auth handshake itself is excluded

That is /api/v3/baas/auth/* (and /api/v3/auth/*), so a failing challenge or verify can never trigger another refresh and spin.

Concurrent calls share one refresh

If ten requests 401 at once, the SDK re-authenticates once and replays all ten — not ten separate handshakes.

Everything above is for code that holds a wallet and signs a challenge — locally, in CI, on your own server. Once you hsuite deploy your smart-app, it runs inside a cluster, and the platform gives it a second way in.

When the platform schedules your app it injects a deployment context into the app’s environment: APP_ID, APP_OWNER_WALLET, CLUSTER_NAME, the in-cluster host URL SMART_HOST_URL, and an APP_TOKEN signed with the validators’ threshold key. BaasClient.fromEnv(env) reads that context and hands you a client that carries APP_TOKEN as its bearer — no web3 challenge, no developer-managed URLs.

import { BaasClient } from '@hsuite/smart-engines-sdk';
// In-cluster: APP_TOKEN + SMART_HOST_URL are present → a client bearing APP_TOKEN.
// Locally: both are absent → fromEnv returns null, so fall back to the
// challenge path from the "BaaS host authentication" section above.
const baas =
BaasClient.fromEnv(process.env) ??
(await connectAndAuthenticateLocally());

fromEnv returns null unless both APP_TOKEN and SMART_HOST_URL are set (a half-populated context won’t half-initialize against your external URL). It also reads APP_ID (or SUBSCRIPTION_APP_ID) for the app id, and talks straight to the in-cluster host (no /host path prefix).

Authenticating your customers (TokenGate Face B)

Section titled “Authenticating your customers (TokenGate Face B)”

Everything above proves your app owns a wallet. A different flow lets your end users prove they own a wallet to your smart-app, with no API keys: the customer signs an app-scoped challenge and the host hands back a short-lived opaque session bearer (≤ 15 min by default — 32 random bytes, base64url, not a JWT). That’s baas.customerSession (also constructable standalone as CustomerSessionClient). The conceptual model — paid-tier NFT gating and all — is in TokenGate; here is the SDK surface.

login runs challenge → sign → verify in one call (re-requesting a fresh challenge if the verify lands on a cluster that never stored the one just signed):

const session = await baas.customerSession.login({
appId: process.env.APP_ID!, // the challenge is bound to this app
chain: 'xrpl',
address: customerAddress,
signFn: (challenge) => signWithCustomerWallet(challenge),
});
console.log(session.token); // opaque bearer — forward it, never decode it

Signer-supplied key on login (SDK 11.1.0). Some wallets only expose their public key (or the CAIP-2 chain id they signed under) after signing. So signFn may now return either a bare signature string or { signature, publicKey?, caipChainId? } (the exported CustomerSessionSignResult type) — a value the signer returns takes precedence over one passed up front, so you can log in with no publicKey supplied ahead of time:

const session = await baas.customerSession.login({
appId: process.env.APP_ID!,
chain: 'hedera',
address: customerAddress,
// publicKey omitted up front — the wallet returns it alongside the signature.
signFn: async (challenge) => {
const { signature, publicKey } = await wallet.sign(challenge);
return { signature, publicKey, caipChainId: 'hedera:testnet' };
},
});

Verification itself is unchanged — the host has always treated publicKey / caipChainId as optional and verifies the signature the same way.

Errors throw an exported CustomerSessionError carrying a numeric statusCode (like every other SDK client; the old .status is a deprecated alias). Your app backend validates an incoming customer bearer with baas.customerSession.validate(bearer) and revokes one with .end(bearer).

If you want to drive the validator handshake manually — to inspect the challenge, sign on a hardware wallet, or wire a custom flow — use ValidatorAuthClient directly:

import { ValidatorAuthClient } from '@hsuite/smart-engines-sdk';
const auth = new ValidatorAuthClient();
// One call that runs all three steps:
const session = await auth.authenticateWithSigner(
'https://gateway.testnet.hsuite.network',
'xrpl', // chain
signer.address,
signer.publicKey,
signer.signFn,
);
console.log(session.token);

Any chain works — you just supply the matching signFn. For Hedera:

import { PrivateKey } from '@hashgraph/sdk';
const pk = PrivateKey.fromString(process.env.HEDERA_PRIVATE_KEY!);
const signer = {
chain: 'hedera' as const,
address: '0.0.789',
publicKey: pk.publicKey.toStringRaw(),
signFn: (challenge: string) =>
Buffer.from(pk.sign(Buffer.from(challenge, 'utf-8'))).toString('hex'),
};

ValidatorAuthClient’s AuthChain union is 'hedera' | 'xrpl' | 'polkadot' | 'stellar' | 'solana', and BaasClient.authenticate accepts 'hedera' | 'xrpl' | 'polkadot' | 'solana'. Of these, Hedera and XRPL are live; the others are coming next.

BaasClient.authenticate returns BaasAuthResult:

Field Description
token Session bearer (carried automatically by the client)
expiresAt Expiry, epoch milliseconds
walletAddress The authenticated wallet
chain The chain you signed with
appId The app the session is bound to
authenticated Optional confirmation flag

ValidatorAuthClient.authenticate returns AuthenticateResponse:

Field Description
token JWT session token (carried automatically by the returned client)
sessionId Unique session identifier
validatorId Assigned validator id (chain:address)
expiresAt Token expiration timestamp
message Informational message