Skip to content

Web3 Authentication

Passwords are a liability: they leak, they get reused, they need a database to store. Web3 authentication replaces “something you know” with “something you can sign.” The user proves they control a wallet by signing a one-time challenge, and the host hands back a short-lived bearer token. That’s the whole model.


Authentication is chain-agnostic: the caller picks the chain. The challenge request carries a chain field, and the host accepts these values:

chain Status
hedera Live
xrpl Live
polkadot Coming next The host accepts the value; the chain is not live yet.
solana Coming next The host accepts the value; the chain is not live yet.

Each app narrows that list further to the login chains its developer accepted.

House chain XRPL is the house chain. The HSUITE token and the subscription NFTs live there. When you don’t have a strong reason to pick another, authenticate on XRPL — the SDK ships a ready-made signer for it (createXrplWeb3Signer).


This flow signs in your app’s developer wallet. Authentication is two HTTP calls:

  1. Challenge — POST /api/v3/baas/auth/challenge with { chain, walletAddress, appId }. The host returns a challengeId and a human-readable message to sign, plus an expiresAt (epoch milliseconds, so you can compare it with Date.now()).
  2. Verify — POST /api/v3/baas/auth/verify with { challengeId, signature, publicKey }. The host delegates signature verification to the validator. It then issues a session only if the wallet is the app’s developer wallet, the app has an active, NFT-minted subscription, and the app itself is active. On success it returns a bearer session token and its expiresAt (epoch milliseconds; it is clamped to the app’s subscription end when that comes sooner).
  3. You then carry that token in the Authorization: Bearer <token> header. GET /api/v3/baas/auth/session validates it; POST /api/v3/baas/auth/logout revokes it.

To sign a wallet out everywhere (a bearer may have leaked, or you want every device signed out), POST /api/v3/baas/auth/logout-all revokes every session the authenticated wallet holds for that app, the calling one included, and returns { "revoked": <number> }. The wallet and the app come from the bearer, never from the request, so it cannot touch another wallet’s sessions or this wallet’s sessions for another app. Every revoked bearer then fails with 401; the revocation is announced to the peer clusters the way a single logout is. The SDK method is baas.logoutAll().

sequenceDiagram
    participant App as Your App (SDK)
    participant Wallet as User's wallet
    participant Host as Smart Host (BaaS)
    participant Val as Validator (verifier)

    App->>Host: POST /api/v3/baas/auth/challenge { chain, walletAddress, appId }
    Host-->>App: { challengeId, message, expiresAt }
    App->>Wallet: sign(message)
    Wallet-->>App: signature
    App->>Host: POST /api/v3/baas/auth/verify { challengeId, signature, publicKey }
    Host->>Val: verify signature (cluster-internal)
    Note over Val: Stateless, chain-aware\nsignature verification
    Val-->>Host: { valid: true }
    Host-->>App: { authenticated: true, token, expiresAt }
    App->>Host: subsequent calls with Authorization: Bearer <token>

The BaasClient wraps both calls into one authenticate() method. You provide a signFn that signs the challenge string; the SDK requests the challenge, signs it, and verifies.

import { BaasClient } from '@hsuite/smart-engines-sdk';
import { createXrplWeb3Signer } from '@hsuite/smart-engines-sdk';
// Zero-config: connect to a known network's gateway
const baas = await BaasClient.connectToCluster({ network: 'testnet', appId });
// XRPL signer (default house chain) from a seed
const signer = createXrplWeb3Signer(xrplSeed);
const result = await baas.authenticate({
chain: signer.chain, // 'xrpl'
walletAddress: signer.address,
publicKey: signer.publicKey,
signFn: signer.signFn, // signs the challenge message
});
// result.token is now stored on the client and sent on every request
await baas.validateSession(); // GET /api/v3/baas/auth/session
await baas.logout(); // POST /api/v3/baas/auth/logout

The host never verifies signatures itself. On verify, it forwards the chain, address, message, and signature to the validator’s stateless verifier, which answers valid or not valid. The validator knows the curve and encoding rules for each chain.

This is why authentication can be chain-agnostic without any blockchain code living on the host — the chain expertise sits in the validator, where the keys already live.


Authentication is a cluster-coordinated flow

Section titled “Authentication is a cluster-coordinated flow”

In production, the gateway hostname (e.g. gateway.testnet.hsuite.network) fronts multiple backend pods across clusters, and DNS round-robin can land your challenge call on one pod and your verify call on a different one. The host handles this correctly: the challenge is written to a shared store at issue time, so any sibling pod can load it on verify.

Treat authentication as a cluster-coordinated flow: you talk to one hostname, and the cluster ensures the challenge you were issued is honored wherever your verify lands. You do not need to pin requests to a specific node.


The challenge → verify flow above is how the app’s developer signs in with a wallet. A deployed smart-app is meant to authenticate without a wallet challenge, using a token the platform gives it.

When the platform deploys your app, it mints a per-app APP_TOKEN by asking the validators’ TSS quorum to BLS-sign it with the app entity’s own DKG group key, and injects it into the app’s deployment context alongside the app’s id and the host’s address. No shared secret is involved.

What is built but not switched on: the host check that would accept the token. It reads two headers, X-App-Token (the minted token) and X-App-Id (the app’s id), and verifies the BLS signature against the app entity’s own public key. This is not the customer-session bearer (X-Customer-Session-Token) that authenticates end-user → smart-app calls.

The SDK already reads the deployment context: BaasClient.fromEnv(process.env) returns a client carrying the token inside the cluster, and null locally, where no APP_TOKEN is injected.

Context Who authenticates How, today
Local dev developer wallet challenge → verify (authenticate())
Deployed in-cluster the smart-app itself the same wallet challenge, with the forwarded XRPL_SEED. The quorum-minted APP_TOKEN is injected but not yet accepted

The wallet-challenge flow above logs in the app’s developer. But the common production shape is different: your deployed smart-app is the thing your users talk to, and it calls the host on their behalf. That makes the app a single door in front of every one of its users — and left unguarded, a door any user could push through to reach another user’s data. The customer session is how the host tells your users apart behind that one app identity.

An end user signs in against your app and receives a customer session: a bearer minted by the platform’s self-serve customer-session challenge/verify (POST /api/v3/baas/customer-session/challenge with { appId, chain, address }, then POST /api/v3/baas/customer-session/verify), stamped with the user’s wallet, your appId, and a customer principal. Your app then forwards that session on every downstream host call, so the host learns which of your users is acting — not merely that your app is.

These headers carry the forwarded identity:

Header Who sets it What it is
Authorization: Bearer <app-session> your app the app’s own app-owner session — the app authenticating as itself
X-Customer-Session-Token your app the end user’s opaque customer bearer (the SDK’s customerToken call option)
X-Customer-Session-Claim the sandbox runtime a JWT claim on calls a deployed function makes back into the host; honored ahead of the token

The one supported shape is app Authorization + forwarded X-Customer-Session-Token: the app vouches for itself, the header names the user.

With the SDK, pass the user’s token per call — the entity read methods and the value-movement methods take a trailing options bag:

// The app is authenticated as itself; forward the end-user's session per call.
await baas.entities.listByOwner(undefined, { customerToken: endUserSessionToken });
await baas.entities.get(entityId, { customerToken: endUserSessionToken });

Fail closed: an identity we can’t verify is refused, not downgraded

Section titled “Fail closed: an identity we can’t verify is refused, not downgraded”

The forwarded session is an authorization scope, not a decorative label. Earlier the platform “gracefully degraded” — a bad token was quietly dropped and the call proceeded with the app’s own tenant-wide authority, the exact behavior that once let one customer reach another’s functions. That degradation is gone. Every BaaS plane (functions, storage, database, messaging, entities, agents) now resolves the forwarded session the same fail-closed way:

Situation Result
No customer header at all Default (strict): 403 on a customer-reachable data-plane route — see “Strict by default” below. Tenant-administration routes stay reachable by the app as itself; under an explicit permissive opt-out the app acts tenant-wide
Token present, but this host runs no customer-session validator 403 — an unverifiable identity is never downgraded to app authority
Token revoked, expired, or invalid 401
Token forged (validation throws) 401 — the error propagates; no catch-and-continue
Session minted for a different appId than the route’s 403
Valid session Scoped to that customer’s wallet
sequenceDiagram
    participant User as End user
    participant App as Your smart-app
    participant Host as Smart Host (BaaS)
    participant Val as Validator (customer-session)

    User->>App: request (carries the user's customer session)
    App->>Host: call with app Authorization + X-Customer-Session-Token
    Host->>Val: validate the forwarded customer session
    Val-->>Host: valid for this appId (or null / throws)
    Note over Host: no header then strict-default 403 on data planes. Bad or foreign then 401 or 403
    Host-->>App: result scoped to that customer

Writes and owner-scoped reads route to the app’s owner cluster, which re-executes the same gate from the forwarded request — not the original. So the customer headers (and the app Authorization) are replayed verbatim on every owner hop. Drop them and the owner would re-run the call session-less, as the app itself, and the scope you enforced at the edge would silently evaporate — which is why the passthrough is load-bearing, not a convenience.


Optional

The developer session is always NFT-gated: verify issues it only while the app holds an active, NFT-minted subscription (see the flow above). Gating your end users on an NFT is optional and up to your app. At customer sign-in the host looks up the wallet’s subscription NFT for your app; a wallet without one still signs in, with no subscription attached, and your app decides what that wallet may do. Read a customer’s subscription at GET /api/v3/baas/marketplace/apps/:appId/subscription. See the subscriptions section for how subscription NFTs are minted.