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.
Chain-agnostic by design
Section titled “Chain-agnostic by design”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).
The flow: challenge, then verify
Section titled “The flow: challenge, then verify”This flow signs in your app’s developer wallet. Authentication is two HTTP calls:
- Challenge —
POST /api/v3/baas/auth/challengewith{ chain, walletAddress, appId }. The host returns achallengeIdand a human-readablemessageto sign, plus anexpiresAt(epoch milliseconds, so you can compare it withDate.now()). - Verify —
POST /api/v3/baas/auth/verifywith{ 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 itsexpiresAt(epoch milliseconds; it is clamped to the app’s subscription end when that comes sooner). - You then carry that token in the
Authorization: Bearer <token>header.GET /api/v3/baas/auth/sessionvalidates it;POST /api/v3/baas/auth/logoutrevokes 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>
With the SDK
Section titled “With the SDK”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 gatewayconst baas = await BaasClient.connectToCluster({ network: 'testnet', appId });
// XRPL signer (default house chain) from a seedconst 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 requestawait baas.validateSession(); // GET /api/v3/baas/auth/sessionawait baas.logout(); // POST /api/v3/baas/auth/logoutHow the host stays key-free
Section titled “How the host stays key-free”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.
Deployed apps: the APP_TOKEN path
Section titled “Deployed apps: the APP_TOKEN path”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 |
Your end users: the customer session
Section titled “Your end users: the customer session”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.
How your app forwards a user
Section titled “How your app forwards a user”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 });Or, at the wire level, alongside the app’s own bearer:
POST /api/v3/baas/functions/<appId>/<functionId>/invokeAuthorization: Bearer <app-session-token>X-Customer-Session-Token: <end-user-session-token>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
The scope survives the cross-cluster hop
Section titled “The scope survives the cross-cluster hop”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.
NFT-gating (optional)
Section titled “NFT-gating (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.